From 57a4485a6724c2e77fc1218d9a52e9cb4af5adf8 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Mon, 27 Jan 2025 23:16:28 +0100 Subject: [PATCH 01/13] Basic tick queue, easy to call, easy to use --- src/engine/world/actor/actor.ts | 17 ++ src/engine/world/actor/tick-queue.ts | 232 ++++++++++++++++++ .../skills/crafting/spinning-wheel.plugin.ts | 224 ++++++++--------- 3 files changed, 347 insertions(+), 126 deletions(-) create mode 100644 src/engine/world/actor/tick-queue.ts diff --git a/src/engine/world/actor/actor.ts b/src/engine/world/actor/actor.ts index 864d77988..15c3d101c 100644 --- a/src/engine/world/actor/actor.ts +++ b/src/engine/world/actor/actor.ts @@ -15,6 +15,7 @@ import { Task, TaskScheduler } from '@engine/task'; import { logger } from '@runejs/common'; import { ObjectConfig } from '@runejs/filestore'; import { QueueableTask } from '@engine/action/pipe/task/queueable-task'; +import { RequestTickOptions, TickQueue } from "@engine/world/actor/tick-queue"; export type ActorType = 'player' | 'npc'; @@ -32,6 +33,7 @@ export abstract class Actor { public readonly inventory: ItemContainer = new ItemContainer(28); public readonly bank: ItemContainer = new ItemContainer(376); public readonly actionPipeline = new ActionPipeline(this); + protected readonly tickQueue = new TickQueue(this); /** * The map of available metadata for this actor. @@ -477,10 +479,25 @@ export abstract class Actor { this.active = false; this.scheduler.clear(); + this.tickQueue.destroy(); } protected tick() { this.scheduler.tick(); + this.tickQueue.tick(); + } + + /** + * Request a tick delay for an action + * @param ticks Number of ticks to wait + * @param options Additional options for the tick request + */ + public async requestActionTicks(ticks: number, options: Omit = {}): Promise { + return this.tickQueue.requestTicks({ + ticks, + blocking: options.blocking, + replace: options.replace + }); } public get position(): Position { diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts new file mode 100644 index 000000000..b45cae224 --- /dev/null +++ b/src/engine/world/actor/tick-queue.ts @@ -0,0 +1,232 @@ +import { Actor } from "@engine/world/actor/actor"; + + + +/** + * Represents different processing lanes for tick tasks + */ +export enum TickLane { + /** + * Default lane for world interaction like skilling and combat + */ + WORLD_INTERACTION = 'world_interaction', +} + + +export interface RequestTickOptions { + /** + * Number of game ticks to wait + */ + ticks: number; + + /** + * If true, prevents other tick requests from being processed while this one is active + * @default false + */ + blocking?: boolean; + + /** + * If true, replaces the current task but maintains its remaining ticks. + * If no current task exists, starts with the full tick count. + * @default false + */ + replace?: boolean; + + /** + * The lane this task should run in + * @default TickLane.WORLD_INTERACTION + */ + lane?: TickLane; +} + + +export interface TickTask { + ticks: number; + promise: Promise; + resolve: () => void; + reject: (reason?: any) => void; + blocking: boolean; + startTick: number; + lane: TickLane; +} + +/** + * Manages tick-based timing and scheduling for an Actor. + * + * The TickQueue allows actors to schedule actions that should occur after a specific number of game ticks. + * It automatically handles movement cancellation and supports blocking/replacing existing tasks. + * + * Each tick represents 600ms in the game world. + */ +export class TickQueue { + private tasksByLane: Map = new Map(); + private currentTick: number = 0; + private movementSubscription: any; + + /** + * Creates a new TickQueue for the given actor + * @param actor The actor this queue belongs to + */ + constructor(private actor: Actor) { + Object.values(TickLane).forEach(lane => { + this.tasksByLane.set(lane, []); + }); + // Automatically cancel tasks when actor moves + this.movementSubscription = this.actor.walkingQueue.movementEvent.subscribe(() => { + this.rejectAllTasks('Movement interrupted action'); + }); + } + + /** + * Request to wait for a specific number of ticks + * + * @param options Configuration options for the tick request + * @returns Promise that resolves when the ticks have elapsed or rejects if interrupted + * @throws Error if trying to add a task while a blocking task exists + * + * @example + * ```typescript + * try { + * // Wait 3 ticks with blocking + * await actor.tickQueue.requestTicks({ ticks: 3, blocking: true }); + * + * // Action after 3 ticks + * actor.doSomething(); + * } catch (error) { + * // Handle interruption + * } + * ``` + */ + public async requestTicks(options: RequestTickOptions): Promise { + const { + ticks, + blocking = false, + replace = false, + lane = TickLane.WORLD_INTERACTION + } = options; + + const laneTasks = this.tasksByLane.get(lane)!; + let startTick = this.currentTick; + let remainingTicks = ticks; + + if (replace && laneTasks.length > 0) { + const currentTask = laneTasks[laneTasks.length - 1]; + const elapsedTicks = this.currentTick - currentTask.startTick; + const currentRemaining = currentTask.ticks - elapsedTicks; + + if (currentRemaining > 0) { + remainingTicks = currentRemaining; + startTick = currentTask.startTick; + } + + currentTask.reject('Task replaced'); + laneTasks.pop(); + } else if (this.isLaneBlocked(lane)) { + throw new Error(`Lane ${lane} is blocked by an existing task`); + } + + let resolveFunc: () => void; + let rejectFunc: (reason?: any) => void; + + const promise = new Promise((resolve, reject) => { + resolveFunc = resolve; + rejectFunc = reject; + }); + + const task: TickTask = { + ticks: remainingTicks, + promise, + resolve: resolveFunc!, + reject: rejectFunc!, + blocking, + startTick, + lane + }; + + laneTasks.push(task); + return promise; + } + + /** + * Advances the tick counter and resolves any completed tasks. + * Called automatically by the actor's tick system. + */ + public tick(): void { + this.currentTick++; + + for (const [lane, tasks] of this.tasksByLane.entries()) { + for (let i = tasks.length - 1; i >= 0; i--) { + const task = tasks[i]; + if (this.currentTick >= task.startTick + task.ticks) { + task.resolve(); + tasks.splice(i, 1); + } + } + } + } + + /** + * Cleans up the tick queue when the actor is destroyed. + * Cancels all pending tasks and unsubscribes from movement events. + */ + public destroy(): void { + if (this.movementSubscription) { + this.movementSubscription.unsubscribe(); + this.movementSubscription = null; + } + + // Reject all tasks in all lanes + this.rejectAllTasks('Actor destroyed'); + } + + private isLaneBlocked(lane: TickLane): boolean { + const tasks = this.tasksByLane.get(lane); + return tasks?.some(task => task.blocking) ?? false; + } + + /** + * Resets all tasks in all lanes to start from the current tick. + * Does not cancel or reject tasks, just resets their start time. + */ + public resetAllTicks(): void { + for (const lane of this.tasksByLane.keys()) { + this.resetLaneTicks(lane); + } + } + + /** + * Rejects all tasks in all lanes + * @param reason The reason for rejection + */ + public rejectAllTasks(reason: string = 'Tasks cancelled'): void { + for (const lane of this.tasksByLane.keys()) { + this.rejectLaneTasks(lane, reason); + } + } + + /** + * Resets the ticks for tasks in a specific lane + * @param lane The lane to reset + */ + public resetLaneTicks(lane: TickLane): void { + const tasks = this.tasksByLane.get(lane); + if (tasks) { + tasks.forEach(task => { + task.startTick = this.currentTick; + }); + } + } + + /** + * Rejects all tasks in a specific lane + * @param lane The lane to reject tasks from + * @param reason The reason for rejection + */ + private rejectLaneTasks(lane: TickLane, reason: string = 'Task cancelled'): void { + const tasks = this.tasksByLane.get(lane); + if (tasks) { + tasks.forEach(task => task.reject(reason)); + tasks.length = 0; + } + } +} diff --git a/src/plugins/skills/crafting/spinning-wheel.plugin.ts b/src/plugins/skills/crafting/spinning-wheel.plugin.ts index 36b10793c..d75b44514 100644 --- a/src/plugins/skills/crafting/spinning-wheel.plugin.ts +++ b/src/plugins/skills/crafting/spinning-wheel.plugin.ts @@ -9,6 +9,7 @@ import { findItem, widgets } from '@engine/config/config-handler'; import { logger } from '@runejs/common'; import { ActorTask } from '@engine/task/impl'; import { Player } from '@engine/world/actor'; +import { take } from "rxjs/operators"; interface Spinnable { input: number | number[]; @@ -23,32 +24,45 @@ interface SpinnableButton { spinnable: Spinnable; } -const ballOfWool: Spinnable = { input: itemIds.wool, output: itemIds.ballOfWool, experience: 2.5, requiredLevel: 1 }; -const bowString: Spinnable = { input: itemIds.flax, output: itemIds.bowstring, experience: 15, requiredLevel: 10 }; +const ballOfWool: Spinnable = { + input: itemIds.wool, + output: itemIds.ballOfWool, + experience: 2.5, + requiredLevel: 1, +}; +const bowString: Spinnable = { + input: itemIds.flax, + output: itemIds.bowstring, + experience: 15, + requiredLevel: 10, +}; const rootsCbowString: Spinnable = { input: [ itemIds.roots.oak, itemIds.roots.willow, itemIds.roots.maple, - itemIds.roots.yew + itemIds.roots.yew, ], output: itemIds.crossbowString, experience: 15, - requiredLevel: 10 + requiredLevel: 10, }; const sinewCbowString: Spinnable = { input: itemIds.sinew, output: itemIds.crossbowString, experience: 15, - requiredLevel: 10 + requiredLevel: 10, }; const magicAmuletString: Spinnable = { input: itemIds.roots.magic, output: itemIds.magicString, experience: 30, - requiredLevel: 19 + requiredLevel: 19, }; -const widgetButtonIds: Map = new Map([ +const widgetButtonIds: Map = new Map< +number, +SpinnableButton +>([ [100, { shouldTakeInput: false, count: 1, spinnable: ballOfWool }], [99, { shouldTakeInput: false, count: 5, spinnable: ballOfWool }], [98, { shouldTakeInput: false, count: 10, spinnable: ballOfWool }], @@ -71,121 +85,72 @@ const widgetButtonIds: Map = new Map { details.player.interfaceState.openWidget(widgets.whatWouldYouLikeToSpin, { - slot: 'screen' + slot: 'screen', }); }; +async function spinProduct(player: Player, spinnable: Spinnable, count: number, animate: boolean = true): Promise { + // Early exit if count is 0 + if (count <= 0) { + return; + } + + let currentItem: number; + let currentItemIndex = 0; -/** - * A task to (repeatedly if needed) spin a product from a spinnable. - */ -class SpinProductTask extends ActorTask { - /** - * The number of ticks that `execute` has been called inside this task. - */ - private elapsedTicks = 0; - - /** - * The number of items that should be spun. - */ - private count: number; - - /** - * The number of items that have been spun. - */ - private created = 0; - - /** - * The spinnable that is being used. - */ - private spinnable: Spinnable; - - /** - * The currently being spun input. - */ - private currentItem: number; - - /** - * The index of the current input being spun. - */ - private currentItemIndex = 0; - - constructor( - player: Player, - spinnable: Spinnable, - count: number, - ) { - super(player); - this.spinnable = spinnable; - this.count = count; + // Determine current input item + if (Array.isArray(spinnable.input)) { + currentItem = spinnable.input[currentItemIndex]; + } else { + currentItem = spinnable.input; } - public execute(): void { - if (this.created === this.count) { - this.stop(); + // Check if out of input material + if (!player.hasItemInInventory(currentItem)) { + if (Array.isArray(spinnable.input) && currentItemIndex < spinnable.input.length - 1) { + currentItemIndex++; + currentItem = spinnable.input[currentItemIndex]; + } else { + const itemName = findItem(currentItem)?.name || ''; + player.sendMessage(`You don't have any ${itemName.toLowerCase()}.`); return; } + } - // As an multiple items can be used for one of the recipes, check if its an array - let isArray = false; - if (Array.isArray(this.spinnable.input)) { - isArray = true; - this.currentItem = this.spinnable.input[0]; - } else { - this.currentItem = this.spinnable.input; - } + // Always do the first action instantly + player.removeFirstItem(currentItem); + player.giveItem(spinnable.output); + player.skills.addExp(Skill.CRAFTING, spinnable.experience); - // Check if out of input material - if (!this.actor.hasItemInInventory(this.currentItem)) { - let cancel = false; - if (isArray) { - if (this.currentItemIndex < ( this.spinnable.input).length) { - this.currentItemIndex++; - this.currentItem = ( this.spinnable.input)[this.currentItemIndex]; - } else { - cancel = true; - } - } else { - cancel = true; - } - if (cancel) { - const itemName = findItem(this.currentItem)?.name || ''; - this.actor.sendMessage(`You don't have any ${itemName.toLowerCase()}.`); - this.stop(); - return; - } - } + // Only play animation on first call + if (animate) { + player.playAnimation(animationIds.spinSpinningWheel); + player.playSound(soundIds.spinWool, 5); + } - // Spinning takes 3 ticks for each item - if (this.elapsedTicks % 3 === 0) { - this.actor.removeFirstItem(this.currentItem); - this.actor.giveItem(this.spinnable.output); - this.actor.skills.addExp(Skill.CRAFTING, this.spinnable.experience); - this.created++; - } + // If there are more items to spin, continue with the async process + if (count > 1) { + try { + // Wait for 3 ticks + await player.requestActionTicks(3, { replace: true }); - // animation plays once every two items - if (this.elapsedTicks % 6 === 0) { - this.actor.playAnimation(animationIds.spinSpinningWheel); - this.actor.outgoingPackets.playSound(soundIds.spinWool, 5); + await spinProduct(player, spinnable, count - 1, !animate); + } catch (error) { + player.sendMessage(error); + return; } - - this.elapsedTicks++; } } -const spinProduct: any = (details: ButtonAction, spinnable: Spinnable, count: number) => { - details.player.enqueueTask(SpinProductTask, [spinnable, count]); -}; - -export const buttonClicked: buttonActionHandler = (details) => { +export const buttonClicked: buttonActionHandler = async (details) => { // Check if player might be spawning widget clientside if (!details.player.interfaceState.findWidget(459)) { return; } - const product = widgetButtonIds.get(details.buttonId); + const product = widgetButtonIds.get(details.buttonId); if (!product) { logger.error(`Unhandled button id ${details.buttonId} for buttonClicked in spinning wheel.`); return; @@ -194,35 +159,42 @@ export const buttonClicked: buttonActionHandler = (details) => { // Close the widget as it is no longer needed details.player.interfaceState.closeAllSlots(); + // Check crafting level requirement if (!details.player.skills.hasLevel(Skill.CRAFTING, product.spinnable.requiredLevel)) { const outputName = findItem(product.spinnable.output)?.name || ''; - - details.player.sendMessage(`You need a crafting level of ${product.spinnable.requiredLevel} to craft ${outputName.toLowerCase()}.`, true); + details.player.sendMessage( + `You need a crafting level of ${product.spinnable.requiredLevel} to craft ${outputName.toLowerCase()}.`, + true + ); return; } if (!product.shouldTakeInput) { - // If the player has not chosen make X, we dont need to get input and can just start the crafting - spinProduct(details, product.spinnable, product.count); + // Start spinning with predefined count + await spinProduct(details.player, product.spinnable, product.count); } else { - // We should prepare for a number to be sent from the client - const numericInputSpinSub = details.player.numericInputEvent.subscribe((number) => { - actionCancelledSpinSub?.unsubscribe(); - numericInputSpinSub?.unsubscribe(); - // When a number is recieved we can start crafting the product - spinProduct(details, product.spinnable, number); - }); - // If the player moves or cancels the number input, we do not want to wait for input, as they could be depositing - // items into their bank. - const actionCancelledSpinSub = details.player.actionsCancelled.subscribe(() => { - actionCancelledSpinSub?.unsubscribe(); - numericInputSpinSub?.unsubscribe(); - }); - // Ask the player to enter how many they want to create - details.player.outgoingPackets.showNumberInputDialogue(); + // Handle "Make X" option + try { + const amount = await new Promise((resolve, reject) => { + const numericInputSub = details.player.numericInputEvent.subscribe(number => { + numericInputSub.unsubscribe(); + resolve(number); + }); + + details.player.actionsCancelled.pipe(take(1)).subscribe(() => { + numericInputSub.unsubscribe(); + reject('Action cancelled'); + }); + + details.player.outgoingPackets.showNumberInputDialogue(); + }); + + await spinProduct(details.player, product.spinnable, amount); + } catch (error) { + // Handle cancellation + details.player.sendMessage(error) + } } - - }; export default { @@ -231,15 +203,15 @@ export default { { type: 'object_interaction', objectIds: objectIds.spinningWheel, - options: [ 'spin' ], + options: ['spin'], walkTo: true, - handler: openSpinningInterface + handler: openSpinningInterface, }, { type: 'button', widgetId: widgets.whatWouldYouLikeToSpin, buttonIds: Array.from(widgetButtonIds.keys()), - handler: buttonClicked - } - ] + handler: buttonClicked, + }, + ], }; From b6c85eba8fba1c4736d8238e58064e8aefd0da90 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Mon, 27 Jan 2025 23:16:45 +0100 Subject: [PATCH 02/13] lint --- src/plugins/skills/crafting/spinning-wheel.plugin.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plugins/skills/crafting/spinning-wheel.plugin.ts b/src/plugins/skills/crafting/spinning-wheel.plugin.ts index d75b44514..1c8b7bd16 100644 --- a/src/plugins/skills/crafting/spinning-wheel.plugin.ts +++ b/src/plugins/skills/crafting/spinning-wheel.plugin.ts @@ -1,5 +1,5 @@ import { objectInteractionActionHandler } from '@engine/action'; -import { buttonActionHandler, ButtonAction } from '@engine/action'; +import { buttonActionHandler } from '@engine/action'; import { soundIds } from '@engine/world/config/sound-ids'; import { itemIds } from '@engine/world/config/item-ids'; import { Skill } from '@engine/world/actor/skills'; @@ -7,9 +7,8 @@ import { animationIds } from '@engine/world/config/animation-ids'; import { objectIds } from '@engine/world/config/object-ids'; import { findItem, widgets } from '@engine/config/config-handler'; import { logger } from '@runejs/common'; -import { ActorTask } from '@engine/task/impl'; import { Player } from '@engine/world/actor'; -import { take } from "rxjs/operators"; +import { take } from 'rxjs/operators'; interface Spinnable { input: number | number[]; From 49d80b286c6b0f389886737fded057f591cfb831 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Mon, 27 Jan 2025 23:17:24 +0100 Subject: [PATCH 03/13] lint --- src/engine/world/actor/actor.ts | 3 +-- src/engine/world/actor/tick-queue.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/engine/world/actor/actor.ts b/src/engine/world/actor/actor.ts index 15c3d101c..56af8b6ba 100644 --- a/src/engine/world/actor/actor.ts +++ b/src/engine/world/actor/actor.ts @@ -13,9 +13,8 @@ import { Pathfinding } from './pathfinding'; import { ActorMetadata } from './metadata'; import { Task, TaskScheduler } from '@engine/task'; import { logger } from '@runejs/common'; -import { ObjectConfig } from '@runejs/filestore'; import { QueueableTask } from '@engine/action/pipe/task/queueable-task'; -import { RequestTickOptions, TickQueue } from "@engine/world/actor/tick-queue"; +import { RequestTickOptions, TickQueue } from '@engine/world/actor/tick-queue'; export type ActorType = 'player' | 'npc'; diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index b45cae224..a38f5a325 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -1,4 +1,4 @@ -import { Actor } from "@engine/world/actor/actor"; +import { Actor } from '@engine/world/actor/actor'; From 16a14725e43f38e50437808cb5a814a8fd4d48fd Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Mon, 27 Jan 2025 23:21:32 +0100 Subject: [PATCH 04/13] whoops a little logic error --- src/engine/world/actor/tick-queue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index a38f5a325..50ca775b2 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -116,7 +116,7 @@ export class TickQueue { if (currentRemaining > 0) { remainingTicks = currentRemaining; - startTick = currentTask.startTick; + startTick = ticks; } currentTask.reject('Task replaced'); From b1fafee72b08f0bed784391f09539de986a3da54 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Thu, 30 Jan 2025 00:19:25 +0100 Subject: [PATCH 05/13] match osrs queue, and delay system --- .../pipe/task/walk-to-object-plugin-task.ts | 16 +- src/engine/net/inbound-packets/walk.packet.ts | 8 +- src/engine/plugins/content-plugin.ts | 13 +- src/engine/plugins/plugin.types.ts | 218 +++++++++++++ .../task/impl/actor-actor-interaction-task.ts | 82 ++--- ...actor-landscape-object-interaction-task.ts | 31 +- src/engine/world/actor/actor.ts | 103 ++++--- src/engine/world/actor/delay-manager.ts | 99 ++++++ src/engine/world/actor/npc.ts | 3 + src/engine/world/actor/player/player.ts | 7 - src/engine/world/actor/tick-queue.ts | 288 +++++++++++------- src/engine/world/actor/walking-queue.ts | 95 +++--- .../skills/crafting/spinning-wheel.plugin.ts | 59 ++-- 13 files changed, 693 insertions(+), 329 deletions(-) create mode 100644 src/engine/plugins/plugin.types.ts create mode 100644 src/engine/world/actor/delay-manager.ts diff --git a/src/engine/action/pipe/task/walk-to-object-plugin-task.ts b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts index 71995c4d6..1229783fd 100644 --- a/src/engine/action/pipe/task/walk-to-object-plugin-task.ts +++ b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts @@ -48,25 +48,18 @@ export class WalkToObjectPluginTask extends ActorL this.data = data; } - /** - * Executed every tick to check if the player has arrived yet and calls the plugins if so. - */ - public execute(): void { - // call super to manage waiting for the movement to complete - super.execute(); - // check if the player has arrived yet + protected onObjectReached(): void { const landscapeObject = this.landscapeObject; const landscapeObjectPosition = this.landscapeObjectPosition; + if (!landscapeObject || !landscapeObjectPosition) { + this.stop(); return; } - // call the relevant plugins this.plugins.forEach(plugin => { - if (!plugin || !plugin.handler) { - return; - } + if (!plugin?.handler) return; const action = { player: this.actor, @@ -78,7 +71,6 @@ export class WalkToObjectPluginTask extends ActorL plugin.handler(action); }); - // this task only executes once, on arrival this.stop(); } } diff --git a/src/engine/net/inbound-packets/walk.packet.ts b/src/engine/net/inbound-packets/walk.packet.ts index 89826de7c..c4303d0d7 100644 --- a/src/engine/net/inbound-packets/walk.packet.ts +++ b/src/engine/net/inbound-packets/walk.packet.ts @@ -9,18 +9,19 @@ const walkPacket = (player: Player, packet: PacketData) => { size -= 14; } - if (!player.canMove()) { + // Check if player can move and isn't delayed + if (!player.canMove() || player.delayManager.isDelayed()) { return; } const totalSteps = Math.floor((size - 5) / 2); - const firstY = buffer.get('short', 'u', 'le'); - const runSteps = buffer.get('byte') === 1; // @TODO forced running + const runSteps = buffer.get('byte') === 1; const firstX = buffer.get('short', 'u', 'le'); const walkingQueue = player.walkingQueue; + // Cancel any weak tasks since movement interrupts them player.actionsCancelled.next('manual-movement'); walkingQueue.clear(); @@ -34,6 +35,7 @@ const walkPacket = (player: Player, packet: PacketData) => { } }; + export default [{ opcode: 73, size: -1, diff --git a/src/engine/plugins/content-plugin.ts b/src/engine/plugins/content-plugin.ts index d4a4b6bc6..ace15d79b 100644 --- a/src/engine/plugins/content-plugin.ts +++ b/src/engine/plugins/content-plugin.ts @@ -1,19 +1,8 @@ import { logger } from '@runejs/common'; import { getFiles } from '@runejs/common/fs'; import { join } from 'path'; +import { ContentPlugin } from '@engine/plugins/plugin.types'; -import { ActionHook } from '@engine/action/hook'; -import { Quest } from '@engine/world/actor/player/quest'; - - -/** - * The definition of a single content plugin. - */ -export class ContentPlugin { - public pluginId: string; - public hooks?: ActionHook[]; - public quests?: Quest[]; -} /** diff --git a/src/engine/plugins/plugin.types.ts b/src/engine/plugins/plugin.types.ts new file mode 100644 index 000000000..11019b37f --- /dev/null +++ b/src/engine/plugins/plugin.types.ts @@ -0,0 +1,218 @@ +import { ActionHook, ActionType } from '@engine/action'; +import { Quest } from '@engine/world/actor/player/quest'; + +// Base hook type that all hook types must extend +export interface BaseHook { + type: ActionType; + handler: (...args: any[]) => any; +} + +// Object interaction hook type +export interface ObjectInteractionHook extends BaseHook { + type: 'object_interaction'; + objectIds: number[]; + options?: string[]; + walkTo?: boolean; +} + +// Button interaction hook type +export interface ButtonHook extends BaseHook { + type: 'button'; + widgetId: number; + buttonIds: number[]; +} + +// Widget interaction hook type +export interface WidgetInteractionHook extends BaseHook { + type: 'widget_interaction'; + widgetIds: number | number[]; + childIds?: number | number[]; + optionId?: number; +} + +// NPC interaction hook type +export interface NpcInteractionHook extends BaseHook { + type: 'npc_interaction'; + npcs?: string | string[]; + options?: string | string[]; + walkTo: boolean; +} + +// Item interaction hook type +export interface ItemInteractionHook extends BaseHook { + type: 'item_interaction'; + itemIds?: number | number[]; + widgets?: { widgetId: number, containerId: number } | { widgetId: number, containerId: number }[]; + options?: string | string[]; +} + +// Item-on-object hook type +export interface ItemOnObjectHook extends BaseHook { + type: 'item_on_object'; + objectIds: number | number[]; + itemIds: number | number[]; + walkTo: boolean; +} + +// Item-on-NPC hook type +export interface ItemOnNpcHook extends BaseHook { + type: 'item_on_npc'; + npcs: string | string[]; + itemIds: number | number[]; + walkTo: boolean; +} + +// Item-on-player hook type +export interface ItemOnPlayerHook extends BaseHook { + type: 'item_on_player'; + itemIds: number | number[]; + walkTo: boolean; +} + +// Item-on-item hook type +export interface ItemOnItemHook extends BaseHook { + type: 'item_on_item'; + items: { item1: number, item2?: number }[]; +} + +// Player/NPC init hook type +export interface InitHook extends BaseHook { + type: 'player_init' | 'npc_init'; +} + +// Player command hook type +export interface PlayerCommandHook extends BaseHook { + type: 'player_command'; + commands: string | string[]; + args?: { + name: string; + type: 'number' | 'string' | 'either'; + defaultValue?: number | string; + }[]; +} + +// Player interaction hook type +export interface PlayerInteractionHook extends BaseHook { + type: 'player_interaction'; + options: string | string[]; + walkTo: boolean; +} + +// Region change hook type +export interface RegionChangeHook extends BaseHook { + type: 'region_change'; + regionType?: string; + regionTypes?: string[]; + teleporting?: boolean; +} + +// Equipment change hook type +export interface EquipmentChangeHook extends BaseHook { + type: 'equipment_change'; + itemIds?: number | number[]; + eventType?: 'equip' | 'unequip'; +} + +// Item swap hook type +export interface ItemSwapHook extends BaseHook { + type: 'item_swap'; + widgetId?: number; + widgetIds?: number[]; +} + +// Move item hook type +export interface MoveItemHook extends BaseHook { + type: 'move_item'; + widgetId?: number; + widgetIds?: number[]; +} + +// Item on world item hook type +export interface ItemOnWorldItemHook extends BaseHook { + type: 'item_on_world_item'; + items: { item?: number, worldItem?: number }[]; +} + +// Spawned item interaction hook type +export interface SpawnedItemInteractionHook extends BaseHook { + type: 'spawned_item_interaction'; + itemIds?: number | number[]; + options: string | string[]; + walkTo: boolean; +} + +// Magic on item hook type +export interface MagicOnItemHook extends BaseHook { + type: 'magic_on_item'; + itemIds?: number | number[]; + spellIds?: number | number[]; +} + +// Magic on player hook type +export interface MagicOnPlayerHook extends BaseHook { + type: 'magic_on_player'; + spellIds?: number | number[]; +} + +// Magic on NPC hook type +export interface MagicOnNpcHook extends BaseHook { + type: 'magic_on_npc'; + npcs?: string | string[]; + spellIds?: number | number[]; +} + +// Prayer hook type +export interface PrayerHook extends BaseHook { + type: 'prayer'; + prayers?: number | number[]; +} + + +// Union of all possible hook types +export type PluginHook = + | ObjectInteractionHook + | ButtonHook + | WidgetInteractionHook + | NpcInteractionHook + | ItemInteractionHook + | ItemOnObjectHook + | ItemOnNpcHook + | ItemOnPlayerHook + | ItemOnItemHook + | ItemOnWorldItemHook + | ItemSwapHook + | MoveItemHook + | SpawnedItemInteractionHook + | MagicOnItemHook + | MagicOnPlayerHook + | MagicOnNpcHook + | InitHook + | PlayerCommandHook + | PlayerInteractionHook + | RegionChangeHook + | EquipmentChangeHook + | PrayerHook; + +// Main plugin type +export interface ContentPlugin { + // Unique identifier for the plugin + pluginId: string; + + // Array of hooks this plugin provides + hooks?: PluginHook[]; + + // Optional quests defined by this plugin + quests?: Quest[]; + + // Optional plugin configuration + config?: { + // Whether the plugin can be hot-reloaded + reloadable?: boolean; + + // Plugin dependencies + dependencies?: string[]; + + // Plugin load priority (higher numbers load first) + priority?: number; + }; +} diff --git a/src/engine/task/impl/actor-actor-interaction-task.ts b/src/engine/task/impl/actor-actor-interaction-task.ts index 45bf47da4..87d5f1b72 100644 --- a/src/engine/task/impl/actor-actor-interaction-task.ts +++ b/src/engine/task/impl/actor-actor-interaction-task.ts @@ -1,7 +1,6 @@ -import { LandscapeObject } from '@runejs/filestore'; -import { Position } from '@engine/world'; import { Actor } from '@engine/world/actor'; import { ActorWalkToTask } from './actor-walk-to-task'; +import { Task } from '@engine/task'; /** * A task for an actor to interact with another actor. @@ -11,78 +10,31 @@ import { ActorWalkToTask } from './actor-walk-to-task'; * * @author jameskmonger */ -export abstract class ActorActorInteractionTask extends ActorWalkToTask Position> { - private _other: TOtherActor; +export class ActorActorInteractionTask extends Task { + protected arrived: boolean = false; - /** - * @param actor The actor executing this task. - * @param TOtherActor The other actor to interact with. - * @param walkOnStart Whether to walk to the other actor on task start. - * Defaults to `false` as the client generally inits a walk on interaction. - */ - constructor ( - actor: TActor, - otherActor: TOtherActor, - walkOnStart = false - ) { - - super( - actor, - () => otherActor.position, - // TODO (jkm) handle other actor size - 1, - walkOnStart - ); - - if (!otherActor) { - this.stop(); - return; - } - - this._other = otherActor; + constructor(protected actor: TActor, protected other: TOther) { + super(); } - /** - * Checks for the continued presence of the other actor 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._other) { + public async execute(): Promise { + if (!this.other || !this.other.position) { this.stop(); return; } - // TODO (jkm) check if other actor was removed from world - // TODO (jkm) check if other actor has moved and repath player if so - } + if (!this.arrived) { + try { + await this.actor.moveTo(this.other); - /** - * Gets the {@link TOtherActor} that this task is interacting with. - * - * @returns If the other actor is still present, and the actor is at the destination, the other actor. - * Otherwise, `null`. - * - * TODO (jameskmonger) unit test this - */ - protected get other(): TOtherActor | 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; - } + // Apply arrive delay only once we reach the target + this.actor.delayManager.applyArriveDelay(); - if (!this._other) { - return null; + this.arrived = true; + } catch (error) { + this.stop(); + return; + } } - - return this._other; } } diff --git a/src/engine/task/impl/actor-landscape-object-interaction-task.ts b/src/engine/task/impl/actor-landscape-object-interaction-task.ts index 0ccacbb23..161c44302 100644 --- a/src/engine/task/impl/actor-landscape-object-interaction-task.ts +++ b/src/engine/task/impl/actor-landscape-object-interaction-task.ts @@ -14,7 +14,7 @@ import { ActorWalkToTask } from './actor-walk-to-task'; export abstract class ActorLandscapeObjectInteractionTask extends ActorWalkToTask { private _landscapeObject: LandscapeObject; private _objectPosition: Position; - + private arriveDelayStarted: boolean = false; /** * @param actor The actor executing this task. * @param landscapeObject The landscape object to interact with. @@ -44,6 +44,11 @@ export abstract class ActorLandscapeObjectInteractionTask 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, 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`); @@ -160,19 +162,19 @@ export abstract class Actor { } public async moveBehind(target: Actor): Promise { - if(this.position.level !== target.position.level) { + if (this.position.level !== target.position.level) { return false; } const distance = Math.floor(this.position.distanceBetween(target.position)); - if(distance > 16) { + if (distance > 16) { this.clearFaceActor(); return false; } let ignoreDestination = true; let desiredPosition = target.position; - if(target.lastMovementPosition) { + if (target.lastMovementPosition) { desiredPosition = target.lastMovementPosition; ignoreDestination = false; } @@ -186,12 +188,12 @@ export abstract class Actor { } public async moveTo(target: Actor): Promise { - if(this.position.level !== target.position.level) { + if (this.position.level !== target.position.level) { return false; } const distance = Math.floor(this.position.distanceBetween(target.position)); - if(distance > 16) { + if (distance > 16) { this.clearFaceActor(); return false; } @@ -210,7 +212,7 @@ export abstract class Actor { this.moveBehind(target); const subscription = target.walkingQueue.movementEvent.subscribe(() => { - if(!this.moveBehind(target)) { + if (!this.moveBehind(target)) { // (Jameskmonger) actionsCancelled is deprecated, casting this to satisfy the typecheck for now this.actionsCancelled.next(null as unknown as ActionCancelType); } @@ -233,11 +235,11 @@ export abstract class Actor { const distance = Math.floor(this.position.distanceBetween(desiredPosition)); - if(distance <= 1) { + if (distance <= 1) { return false; } - if(distance > 16) { + if (distance > 16) { this.clearFaceActor(); this.metadata.faceActorClearedByWalking = true; return false; @@ -252,41 +254,41 @@ export abstract class Actor { } public face(face: Position | Actor | null, clearWalkingQueue: boolean = true, autoClear: boolean = true, clearedByWalking: boolean = true): void { - if(face === null) { + if (face === null) { this.clearFaceActor(); this.updateFlags.facePosition = null; return; } - if(face instanceof Position) { + if (face instanceof Position) { this.updateFlags.facePosition = face; - } else if(face instanceof Actor) { + } else if (face instanceof Actor) { this.updateFlags.faceActor = face; this.metadata.faceActor = face; this.metadata.faceActorClearedByWalking = clearedByWalking; - if(autoClear) { + if (autoClear) { setTimeout(() => { this.clearFaceActor(); }, 20000); } } - if(clearWalkingQueue) { + if (clearWalkingQueue) { this.walkingQueue.clear(); this.walkingQueue.valid = false; } } public clearFaceActor(): void { - if(this.metadata.faceActor) { + if (this.metadata.faceActor) { this.updateFlags.faceActor = null; this.metadata.faceActor = undefined; } } public playAnimation(animation: number | Animation | null): void { - if(typeof animation === 'number') { + if (typeof animation === 'number') { animation = { id: animation, delay: 0 }; } @@ -298,7 +300,7 @@ export abstract class Actor { } public playGraphics(graphics: number | Graphic): void { - if(typeof graphics === 'number') { + if (typeof graphics === 'number') { graphics = { id: graphics, delay: 0, height: 120 }; } @@ -320,6 +322,7 @@ export abstract class Actor { public giveItem(item: number | Item): boolean { return this.inventory.add(item) !== null; } + public giveBankItem(item: number | Item): boolean { return this.bank.add(item) !== null; } @@ -327,6 +330,7 @@ export abstract class Actor { public hasItemInInventory(item: number | Item): boolean { return this.inventory.has(item); } + public hasItemInBank(item: number | Item): boolean { return this.bank.has(item); } @@ -336,6 +340,9 @@ export abstract class Actor { } public canMove(): boolean { + if (this.delayManager.isDelayed()) { + return false; + } // In the future, there will undoubtedly be various reasons for the // actor to not be able to move, but for now we are returning true. return true; @@ -352,13 +359,13 @@ export abstract class Actor { } public moveSomewhere(): void { - if(!this.canMove()) { + if (!this.canMove()) { return; } - if(this.isNpc) { + if (this.isNpc) { const nearbyPlayers = activeWorld.findNearbyPlayers(this.position, 24, this.instance?.instanceId); - if(nearbyPlayers.length === 0) { + if (nearbyPlayers.length === 0) { // No need for this actor to move if there are no players nearby to witness it, save some memory. :) return; } @@ -366,7 +373,7 @@ export abstract class Actor { const movementChance = Math.floor(Math.random() * 10); - if(movementChance < 7) { + if (movementChance < 7) { return; } @@ -374,17 +381,17 @@ export abstract class Actor { let py = this.position.y; let movementAllowed = false; - while(!movementAllowed) { + while (!movementAllowed) { px = this.position.x; py = this.position.y; const moveXChance = Math.floor(Math.random() * 10); - if(moveXChance > 6) { + if (moveXChance > 6) { const moveXAmount = Math.floor(Math.random() * 5); const moveXMod = Math.floor(Math.random() * 2); - if(moveXMod === 0) { + if (moveXMod === 0) { px -= moveXAmount; } else { px += moveXAmount; @@ -393,11 +400,11 @@ export abstract class Actor { const moveYChance = Math.floor(Math.random() * 10); - if(moveYChance > 6) { + if (moveYChance > 6) { const moveYAmount = Math.floor(Math.random() * 5); const moveYMod = Math.floor(Math.random() * 2); - if(moveYMod === 0) { + if (moveYMod === 0) { py -= moveYAmount; } else { py += moveYAmount; @@ -406,14 +413,14 @@ export abstract class Actor { let valid = true; - if(!this.withinBounds(px, py)) { + if (!this.withinBounds(px, py)) { valid = false; } movementAllowed = valid; } - if(px !== this.position.x || py !== this.position.y) { + if (px !== this.position.x || py !== this.position.y) { this.walkingQueue.clear(); this.walkingQueue.valid = true; this.walkingQueue.add(px, py); @@ -421,7 +428,7 @@ export abstract class Actor { } public forceMovement(direction: number, steps: number): void { - if(!this.canMove()) { + if (!this.canMove()) { return; } @@ -429,20 +436,20 @@ export abstract class Actor { let py = this.position.y; let movementAllowed = false; - while(!movementAllowed) { + while (!movementAllowed) { px = this.position.x; py = this.position.y; const movementDirection = directionFromIndex(direction); - if(!movementDirection) { + if (!movementDirection) { return; } let valid = true; - for(let step = 0; step < steps; step++) { + for (let step = 0; step < steps; step++) { px += movementDirection.deltaX; py += movementDirection.deltaY; - if(!this.withinBounds(px, py)) { + if (!this.withinBounds(px, py)) { valid = false; } @@ -451,7 +458,7 @@ export abstract class Actor { movementAllowed = valid; } - if(px !== this.position.x || py !== this.position.y) { + if (px !== this.position.x || py !== this.position.y) { this.walkingQueue.clear(); this.walkingQueue.valid = true; this.walkingQueue.add(px, py); @@ -482,8 +489,15 @@ export abstract class Actor { } protected tick() { - this.scheduler.tick(); + // Process delays first + this.delayManager.tick(); + + // Only process queue if not delayed this.tickQueue.tick(); + + + // Always process scheduler since it may have soft tasks + this.scheduler.tick(); } /** @@ -491,11 +505,10 @@ export abstract class Actor { * @param ticks Number of ticks to wait * @param options Additional options for the tick request */ - public async requestActionTicks(ticks: number, options: Omit = {}): Promise { + public async requestTickDelay(ticks: number, options: Omit = {}): Promise { return this.tickQueue.requestTicks({ ticks, - blocking: options.blocking, - replace: options.replace + inheritCooldown: options.inheritCooldown }); } @@ -504,7 +517,7 @@ export abstract class Actor { } public set position(value: Position) { - if(!this._position) { + if (!this._position) { this._lastMapRegionUpdatePosition = value; } diff --git a/src/engine/world/actor/delay-manager.ts b/src/engine/world/actor/delay-manager.ts new file mode 100644 index 000000000..02993c8eb --- /dev/null +++ b/src/engine/world/actor/delay-manager.ts @@ -0,0 +1,99 @@ +import { Actor } from '@engine/world/actor/actor'; + + +/** + * Types of delays that can be applied to actors + * + * @see {@link https://osrs-docs.com/docs/mechanics/delays/#known-delays} + */ +export enum DelayType { + /** + * 1-tick delay applied when an entity moves to synchronize animations + */ + ARRIVE = 'arrive', + + /** + * Normal delay that prevents actions for a specified duration + */ + NORMAL = 'normal' +} + +/** + * Manages delay states and timing for an Actor. + * + * Delays prevent most actions and script execution: + * - Blocks queue processing (except soft tasks) + * - Blocks entity interactions + * - Blocks most interface interactions + * - Allows some predetermined movement to continue + * + * @see {@link https://osrs-docs.com/docs/mechanics/delays/} - OSRS Delay System + */ +export class DelayManager { + /** Current delay type if any */ + private currentDelay: DelayType | null = null; + + /** Ticks remaining in current delay */ + private delayTicks: number = 0; + + /** Tick when current delay started */ + private delayStartTick: number = 0; + + constructor(private actor: Actor) {} + + /** + * Apply an arrive delay (1 tick) to synchronize with movement + */ + public applyArriveDelay(): void { + // Only apply if no current delay + if (!this.currentDelay) { + this.currentDelay = DelayType.ARRIVE; + this.delayTicks = 1; + this.delayStartTick = this.actor.tickQueue.currentTick; + } + } + + /** + * Apply a normal delay for a specified duration + * @param ticks Number of ticks to delay for + */ + public applyDelay(ticks: number): void { + // Override any current delay + this.currentDelay = DelayType.NORMAL; + this.delayTicks = ticks; + this.delayStartTick = this.actor.tickQueue.currentTick; + } + + /** + * Process delays on each game tick + */ + public tick(): void { + if (this.delayTicks > 0) { + this.delayTicks--; + if (this.delayTicks === 0) { + this.currentDelay = null; + } + } + } + + /** + * Check if actor is currently delayed + */ + public isDelayed(): boolean { + return this.delayTicks > 0; + } + + /** + * Get the type of current delay if any + */ + public getDelayType(): DelayType | null { + return this.currentDelay; + } + + /** + * Get remaining ticks in current delay + */ + public getRemainingTicks(): number { + return this.delayTicks; + } +} diff --git a/src/engine/world/actor/npc.ts b/src/engine/world/actor/npc.ts index fef7837d9..9b43418c6 100644 --- a/src/engine/world/actor/npc.ts +++ b/src/engine/world/actor/npc.ts @@ -209,6 +209,9 @@ export class Npc extends Actor { * Whether or not the Npc can currently move. */ public canMove(): boolean { + if(!super.canMove()) { + return false; + } if(this.metadata.following) { return false; } diff --git a/src/engine/world/actor/player/player.ts b/src/engine/world/actor/player/player.ts index 4647d1763..a9b228994 100644 --- a/src/engine/world/actor/player/player.ts +++ b/src/engine/world/actor/player/player.ts @@ -700,13 +700,6 @@ export class Player extends Actor { } } - public canMove(): boolean { - if (this.metadata?.castingStationarySpell) { - return false; - } - return true; - } - public removeFirstItem(item: number | Item): number { const slot = this.inventory.removeFirst(item); diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index 50ca775b2..97a832326 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -1,97 +1,145 @@ import { Actor } from '@engine/world/actor/actor'; - - +import { Player } from '@engine/world/actor/player'; /** - * Represents different processing lanes for tick tasks + * Represents different queue types for tick tasks. + * + * @see {@link https://osrs-docs.com/docs/mechanics/queues/#queue-types} */ -export enum TickLane { +export enum QueueType { /** - * Default lane for world interaction like skilling and combat + * Weak tasks are removed by any interruption (movement, combat, interfaces, etc) + * and are cleared if any Strong tasks exist in the queue */ - WORLD_INTERACTION = 'world_interaction', -} + WEAK = 'weak', + /** + * Normal tasks are processed normally but skipped if a modal interface is open + */ + NORMAL = 'normal', -export interface RequestTickOptions { /** - * Number of game ticks to wait + * Strong tasks remove all Weak tasks from the queue and force close modal interfaces */ - ticks: number; + STRONG = 'strong', /** - * If true, prevents other tick requests from being processed while this one is active - * @default false + * Soft tasks cannot be interrupted and always execute when their time comes, + * even during delays. Also forces modal interfaces closed. */ - blocking?: boolean; + SOFT = 'soft' +} +/** + * Configuration options for requesting a tick task + */ +export interface RequestTickOptions { /** - * If true, replaces the current task but maintains its remaining ticks. - * If no current task exists, starts with the full tick count. - * @default false + * Number of game ticks to wait before executing */ - replace?: boolean; + ticks: number; /** - * The lane this task should run in - * @default TickLane.WORLD_INTERACTION + * The type of task - determines how it behaves regarding interruptions + * @default QueueType.NORMAL */ - lane?: TickLane; + type?: QueueType; + } + +/** + * Represents a queued tick task + */ export interface TickTask { + /** Number of ticks to wait */ ticks: number; + /** Promise that resolves when task completes */ promise: Promise; + /** Function to resolve the promise */ resolve: () => void; + /** Function to reject the promise */ reject: (reason?: any) => void; - blocking: boolean; + /** Type of task */ + type: QueueType; + /** Tick number when task was started */ startTick: number; - lane: TickLane; } + /** * Manages tick-based timing and scheduling for an Actor. * - * The TickQueue allows actors to schedule actions that should occur after a specific number of game ticks. - * It automatically handles movement cancellation and supports blocking/replacing existing tasks. + * Implements OSRS-style task queuing with different task types: + * - WEAK tasks are removed by interruptions or presence of STRONG tasks + * - NORMAL tasks are processed normally but skip if modal interface is open + * - STRONG tasks clear WEAK tasks and force close modal interfaces + * - SOFT tasks cannot be interrupted and always execute + * + * Tasks are processed in order and can be delayed by game ticks. Delayed actors + * cannot process most tasks except for SOFT tasks. * * Each tick represents 600ms in the game world. + * + * @see {@link https://osrs-docs.com/docs/mechanics/queues/} - OSRS Queue Documentation + * @see {@link https://osrs-docs.com/docs/mechanics/delays/} - OSRS Delay System + * TODO: @see {@link https://oldschool.runescape.wiki/w/Tick_manipulation} */ + export class TickQueue { - private tasksByLane: Map = new Map(); - private currentTick: number = 0; - private movementSubscription: any; + /** Current game tick counter */ + public currentTick: number = 0; + /** List of queued tasks */ + private tasks: TickTask[] = []; /** * Creates a new TickQueue for the given actor * @param actor The actor this queue belongs to */ constructor(private actor: Actor) { - Object.values(TickLane).forEach(lane => { - this.tasksByLane.set(lane, []); + // Subscribe to movement events to clear weak tasks + this.actor.walkingQueue.movementEvent.subscribe(() => { + this.clearWeakTasks('Movement interrupted action'); }); - // Automatically cancel tasks when actor moves - this.movementSubscription = this.actor.walkingQueue.movementEvent.subscribe(() => { - this.rejectAllTasks('Movement interrupted action'); + } + + /** + * Removes all WEAK tasks from the queue + * @param reason The reason for clearing tasks, passed to reject() + */ + private clearWeakTasks(reason: string): void { + this.tasks = this.tasks.filter(task => { + if (task.type === QueueType.WEAK) { + task.reject(reason); + return false; + } + return true; }); } /** * Request to wait for a specific number of ticks * + * Tasks are queued based on their type: + * - WEAK tasks can be interrupted by movement/actions + * - NORMAL tasks skip if modal interface is open + * - STRONG tasks clear WEAK tasks and close modals + * - SOFT tasks cannot be interrupted + * * @param options Configuration options for the tick request * @returns Promise that resolves when the ticks have elapsed or rejects if interrupted * @throws Error if trying to add a task while a blocking task exists * * @example * ```typescript + * // Wait 3 ticks with WEAK type (interruptible) * try { - * // Wait 3 ticks with blocking - * await actor.tickQueue.requestTicks({ ticks: 3, blocking: true }); - * - * // Action after 3 ticks - * actor.doSomething(); + * await actor.tickQueue.requestTicks({ + * ticks: 3, + * type: QueueType.WEAK + * }); + * // Action after 3 ticks if not interrupted * } catch (error) { * // Handle interruption * } @@ -100,29 +148,24 @@ export class TickQueue { public async requestTicks(options: RequestTickOptions): Promise { const { ticks, - blocking = false, - replace = false, - lane = TickLane.WORLD_INTERACTION + type = QueueType.NORMAL } = options; - const laneTasks = this.tasksByLane.get(lane)!; - let startTick = this.currentTick; - let remainingTicks = ticks; - - if (replace && laneTasks.length > 0) { - const currentTask = laneTasks[laneTasks.length - 1]; - const elapsedTicks = this.currentTick - currentTask.startTick; - const currentRemaining = currentTask.ticks - elapsedTicks; + // Handle STRONG tasks entering queue + if (type === QueueType.STRONG) { + // Clear weak tasks first + this.clearWeakTasks('Strong task present'); - if (currentRemaining > 0) { - remainingTicks = currentRemaining; - startTick = ticks; + // Force close modal interfaces immediately + if (this.actor instanceof Player) { + this.actor.interfaceState.closeAllSlots(); } + } - currentTask.reject('Task replaced'); - laneTasks.pop(); - } else if (this.isLaneBlocked(lane)) { - throw new Error(`Lane ${lane} is blocked by an existing task`); + // Handle SOFT tasks entering queue + if (type === QueueType.SOFT && this.actor instanceof Player) { + // Force close modal interfaces immediately + this.actor.interfaceState.closeAllSlots(); } let resolveFunc: () => void; @@ -134,99 +177,122 @@ export class TickQueue { }); const task: TickTask = { - ticks: remainingTicks, + ticks, promise, resolve: resolveFunc!, reject: rejectFunc!, - blocking, - startTick, - lane + type, + startTick: this.currentTick }; - laneTasks.push(task); + this.tasks.push(task); return promise; } /** - * Advances the tick counter and resolves any completed tasks. - * Called automatically by the actor's tick system. + * Processes queued tasks on each game tick. + * + * Processing rules: + * - Skips if actor is delayed (except SOFT tasks) + * - NORMAL tasks skip if modal interface open + * - STRONG/SOFT tasks force close modal interfaces + * - Continues processing until no tasks were processed in a loop */ public tick(): void { this.currentTick++; - for (const [lane, tasks] of this.tasksByLane.entries()) { - for (let i = tasks.length - 1; i >= 0; i--) { - const task = tasks[i]; - if (this.currentTick >= task.startTick + task.ticks) { - task.resolve(); - tasks.splice(i, 1); + + // Check if actor is delayed + const isDelayed = this.actor.delayManager.isDelayed(); + + let processedTasks = 0; + do { + processedTasks = 0; + + for (let i = this.tasks.length - 1; i >= 0; i--) { + const task = this.tasks[i]; + + // Only process task if: + // 1. It's a SOFT task (these ignore delays) + // 2. OR actor is not delayed and task can be processed + if (task.type === QueueType.SOFT || (!isDelayed && this.canProcessTask(task))) { + if (this.currentTick >= task.startTick + task.ticks) { + // Handle modal interfaces for STRONG/SOFT tasks + if (this.actor instanceof Player && + (task.type === QueueType.STRONG || task.type === QueueType.SOFT)) { + this.actor.interfaceState.closeAllSlots(); + } + + task.resolve(); + this.tasks.splice(i, 1); + processedTasks++; + } } } - } + } while (processedTasks > 0 && this.tasks.length > 0); } /** - * Cleans up the tick queue when the actor is destroyed. - * Cancels all pending tasks and unsubscribes from movement events. + * Checks if a task can be processed based on its type and current conditions + * @param task The task to check + * @returns True if task can be processed, false otherwise */ - public destroy(): void { - if (this.movementSubscription) { - this.movementSubscription.unsubscribe(); - this.movementSubscription = null; + + private canProcessTask(task: TickTask): boolean { + // Check if task should execute in future + if (this.currentTick < task.startTick + task.ticks) { + return false; } - // Reject all tasks in all lanes - this.rejectAllTasks('Actor destroyed'); - } + // For players, handle modal interfaces + if (this.actor instanceof Player) { + // NORMAL tasks skip if modal interface is open + if (task.type === QueueType.NORMAL + // && this.actor.interfaceState.hasModalOpen() // TODO: implement in player + ) { + return false; + } - private isLaneBlocked(lane: TickLane): boolean { - const tasks = this.tasksByLane.get(lane); - return tasks?.some(task => task.blocking) ?? false; - } + // STRONG/SOFT tasks force close interfaces before processing + if (task.type === QueueType.STRONG || task.type === QueueType.SOFT) { + this.actor.interfaceState.closeAllSlots(); + } + } - /** - * Resets all tasks in all lanes to start from the current tick. - * Does not cancel or reject tasks, just resets their start time. - */ - public resetAllTicks(): void { - for (const lane of this.tasksByLane.keys()) { - this.resetLaneTicks(lane); + // Weak tasks interrupted by strong tasks + if (task.type === QueueType.WEAK && this.hasStrongTask()) { + task.reject('Strong task present'); + return false; } + + return true; } /** - * Rejects all tasks in all lanes - * @param reason The reason for rejection + * Checks if queue contains any strong tasks */ - public rejectAllTasks(reason: string = 'Tasks cancelled'): void { - for (const lane of this.tasksByLane.keys()) { - this.rejectLaneTasks(lane, reason); - } + private hasStrongTask(): boolean { + return this.tasks.some(task => task.type === QueueType.STRONG); } /** - * Resets the ticks for tasks in a specific lane - * @param lane The lane to reset + * Cleans up the tick queue when the actor is destroyed. + * Rejects all pending tasks. */ - public resetLaneTicks(lane: TickLane): void { - const tasks = this.tasksByLane.get(lane); - if (tasks) { - tasks.forEach(task => { - task.startTick = this.currentTick; - }); - } + public destroy(): void { + this.rejectAllTasks('Actor destroyed'); } /** - * Rejects all tasks in a specific lane - * @param lane The lane to reject tasks from + * Rejects all tasks in the queue * @param reason The reason for rejection */ - private rejectLaneTasks(lane: TickLane, reason: string = 'Task cancelled'): void { - const tasks = this.tasksByLane.get(lane); - if (tasks) { - tasks.forEach(task => task.reject(reason)); - tasks.length = 0; + private rejectAllTasks(reason: string = 'Tasks cancelled'): void { + while (this.tasks.length > 0) { + const task = this.tasks.pop(); + if (task) { + task.reject(reason); + } } } } diff --git a/src/engine/world/actor/walking-queue.ts b/src/engine/world/actor/walking-queue.ts index 0e9902a31..3f0ec7b91 100644 --- a/src/engine/world/actor/walking-queue.ts +++ b/src/engine/world/actor/walking-queue.ts @@ -4,8 +4,7 @@ import { Player } from './player/player'; import { Npc } from './npc'; import { regionChangeActionFactory } from '@engine/action'; import { Subject } from 'rxjs'; -import { activeWorld } from '@engine/world'; -import { logger } from '@runejs/common'; +import { activeWorld, Chunk } from '@engine/world'; /** @@ -144,18 +143,17 @@ export class WalkingQueue { } public process(): void { - if(this.actor.busy || this.queue.length === 0 || !this.valid) { + if(this.actor.busy || this.queue.length === 0 || !this.valid || this.actor.delayManager.isDelayed()) { this.resetDirections(); return; } const walkPosition = this.queue.shift(); - if (!walkPosition) { return; } - if(this.actor.metadata.faceActorClearedByWalking === undefined || this.actor.metadata.faceActorClearedByWalking) { + if(this.actor.metadata.faceActorClearedByWalking) { this.actor.clearFaceActor(); } @@ -179,67 +177,66 @@ export class WalkingQueue { let runDir = -1; - // @TODO npc running - if(this.actor instanceof Player) { - if(this.actor.settings.runEnabled && this.queue.length !== 0) { - const runPosition = this.queue.shift(); - - if (!runPosition) { - return; - } - - if(this.actor.pathfinding.canMoveTo(walkPosition, runPosition)) { - const runDiffX = runPosition.x - walkPosition.x; - const runDiffY = runPosition.y - walkPosition.y; - runDir = this.calculateDirection(runDiffX, runDiffY); - - if(runDir != -1) { - this.actor.lastMovementPosition = this.actor.position; - this.actor.position = runPosition; - } - } else { - this.resetDirections(); - this.clear(); + // Process running if enabled and more steps exist + if(this.actor instanceof Player && this.actor.settings.runEnabled && this.queue.length !== 0) { + const runPosition = this.queue.shift(); + if (runPosition && this.actor.pathfinding.canMoveTo(walkPosition, runPosition)) { + const runDiffX = runPosition.x - walkPosition.x; + const runDiffY = runPosition.y - walkPosition.y; + runDir = this.calculateDirection(runDiffX, runDiffY); + + if(runDir !== -1) { + this.actor.lastMovementPosition = this.actor.position; + this.actor.position = runPosition; } } } this.actor.walkDirection = walkDir; this.actor.runDirection = runDir; - - if(runDir !== -1) { - this.actor.faceDirection = runDir; - } else { - this.actor.faceDirection = walkDir; - } + this.actor.faceDirection = runDir !== -1 ? runDir : walkDir; const newChunk = activeWorld.chunkManager.getChunkForWorldPosition(this.actor.position); - this.movementEvent.next(this.actor.position); + this.handleChunkUpdate(oldChunk, newChunk, originalPosition); + } else { + this.resetDirections(); + this.clear(); + } + } + + /** + * Handles chunk updates and region changes when an actor moves between chunks + * @param oldChunk The chunk the actor is moving from + * @param newChunk The chunk the actor is moving to + * @param originalPosition The actor's original position before movement + */ + private handleChunkUpdate(oldChunk: Chunk, newChunk: Chunk, originalPosition: Position): void { + if(!oldChunk.equals(newChunk)) { if(this.actor instanceof Player) { - const mapDiffX = this.actor.position.x - (lastMapRegionUpdatePosition.chunkX * 8); - const mapDiffY = this.actor.position.y - (lastMapRegionUpdatePosition.chunkY * 8); + // Handle map region updates for players + const mapDiffX = this.actor.position.x - (this.actor.lastMapRegionUpdatePosition.chunkX * 8); + const mapDiffY = this.actor.position.y - (this.actor.lastMapRegionUpdatePosition.chunkY * 8); + if(mapDiffX < 16 || mapDiffX > 87 || mapDiffY < 16 || mapDiffY > 87) { this.actor.updateFlags.mapRegionUpdateRequired = true; this.actor.lastMapRegionUpdatePosition = this.actor.position; } - } - if(!oldChunk.equals(newChunk)) { - if(this.actor instanceof Player) { - this.actor.metadata.updateChunk = { newChunk, oldChunk }; - - this.actor.actionPipeline.call('region_change', regionChangeActionFactory( - this.actor, originalPosition, this.actor.position)); - } else if(this.actor instanceof Npc) { - oldChunk.removeNpc(this.actor); - newChunk.addNpc(this.actor); - } + // Update chunk references + oldChunk.removePlayer(this.actor); + newChunk.addPlayer(this.actor); + this.actor.metadata.updateChunk = { newChunk, oldChunk }; + + // Call region change action + this.actor.actionPipeline.call('region_change', regionChangeActionFactory( + this.actor, originalPosition, this.actor.position)); + } else if(this.actor instanceof Npc) { + // Handle NPC chunk updates + oldChunk.removeNpc(this.actor); + newChunk.addNpc(this.actor); } - } else { - this.resetDirections(); - this.clear(); } } diff --git a/src/plugins/skills/crafting/spinning-wheel.plugin.ts b/src/plugins/skills/crafting/spinning-wheel.plugin.ts index 1c8b7bd16..6f4ea3158 100644 --- a/src/plugins/skills/crafting/spinning-wheel.plugin.ts +++ b/src/plugins/skills/crafting/spinning-wheel.plugin.ts @@ -9,6 +9,8 @@ import { findItem, widgets } from '@engine/config/config-handler'; import { logger } from '@runejs/common'; import { Player } from '@engine/world/actor'; import { take } from 'rxjs/operators'; +import { ContentPlugin } from '@engine/plugins/plugin.types'; +import { QueueType } from '@engine/world/actor/tick-queue'; interface Spinnable { input: number | number[]; @@ -90,12 +92,8 @@ export const openSpinningInterface: objectInteractionActionHandler = (details) = slot: 'screen', }); }; -async function spinProduct(player: Player, spinnable: Spinnable, count: number, animate: boolean = true): Promise { - // Early exit if count is 0 - if (count <= 0) { - return; - } +function processSpin(player: Player, spinnable: Spinnable): boolean { let currentItem: number; let currentItemIndex = 0; @@ -114,35 +112,48 @@ async function spinProduct(player: Player, spinnable: Spinnable, count: number, } else { const itemName = findItem(currentItem)?.name || ''; player.sendMessage(`You don't have any ${itemName.toLowerCase()}.`); - return; + return false; } } - // Always do the first action instantly + // Process the spinning action player.removeFirstItem(currentItem); player.giveItem(spinnable.output); player.skills.addExp(Skill.CRAFTING, spinnable.experience); - // Only play animation on first call - if (animate) { - player.playAnimation(animationIds.spinSpinningWheel); - player.playSound(soundIds.spinWool, 5); + return true; +} +async function spinProduct(player: Player, spinnable: Spinnable, count: number): Promise { + // Early exit if count is 0 + if (count <= 0) { + return; } - // If there are more items to spin, continue with the async process - if (count > 1) { - try { - // Wait for 3 ticks - await player.requestActionTicks(3, { replace: true }); + try { + for (let i = 0; i < count; i++) { + // Queue as WEAK task + await player.tickQueue.requestTicks({ + ticks: i === 0 ? 0 : 3, // First action immediate, then 3 tick spacing + type: QueueType.WEAK + }); - await spinProduct(player, spinnable, count - 1, !animate); - } catch (error) { - player.sendMessage(error); - return; + // Play animation and sound each time + player.playAnimation(animationIds.spinSpinningWheel); + player.playSound(soundIds.spinWool, 5); + + // Process the spin + if (!processSpin(player, spinnable)) { + break; + } } + } catch (error) { + // Queue was interrupted (movement/combat/etc) + player.sendMessage(`Spinning interrupted: ${error}`); } } + + export const buttonClicked: buttonActionHandler = async (details) => { // Check if player might be spawning widget clientside if (!details.player.interfaceState.findWidget(459)) { @@ -169,7 +180,7 @@ export const buttonClicked: buttonActionHandler = async (details) => { } if (!product.shouldTakeInput) { - // Start spinning with predefined count + // Start spinning with predefined count using WEAK queue await spinProduct(details.player, product.spinnable, product.count); } else { // Handle "Make X" option @@ -191,20 +202,20 @@ export const buttonClicked: buttonActionHandler = async (details) => { await spinProduct(details.player, product.spinnable, amount); } catch (error) { // Handle cancellation - details.player.sendMessage(error) + details.player.sendMessage(error); } } }; -export default { +export default { pluginId: 'rs:spinning_wheel', hooks: [ { type: 'object_interaction', objectIds: objectIds.spinningWheel, options: ['spin'], - walkTo: true, handler: openSpinningInterface, + walkTo: true, }, { type: 'button', From 3666245dd8100ffe1e18e21d516787aae0429446 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Thu, 30 Jan 2025 23:45:23 +0100 Subject: [PATCH 06/13] Port woodcutting, fix walk to for larger objects --- .../pipe/task/walk-to-object-plugin-task.ts | 25 ++- src/engine/plugins/plugin.types.ts | 3 +- src/engine/world/actor/tick-queue.ts | 37 +++- src/engine/world/actor/timing/action-timer.ts | 20 ++ src/engine/world/config/harvestable-object.ts | 177 +-------------- src/engine/world/items/item-container.ts | 4 + src/engine/world/position.ts | 31 ++- src/plugins/skills/woodcutting/chance.ts | 23 -- src/plugins/skills/woodcutting/index.ts | 27 --- .../skills/woodcutting/woodcutting-task.ts | 189 ---------------- .../woodcutting/woodcutting.constants.ts | 206 ++++++++++++++++++ .../skills/woodcutting/woodcutting.plugin.ts | 168 ++++++++++++++ 12 files changed, 474 insertions(+), 436 deletions(-) create mode 100644 src/engine/world/actor/timing/action-timer.ts delete mode 100644 src/plugins/skills/woodcutting/chance.ts delete mode 100644 src/plugins/skills/woodcutting/index.ts delete mode 100644 src/plugins/skills/woodcutting/woodcutting-task.ts create mode 100644 src/plugins/skills/woodcutting/woodcutting.constants.ts create mode 100644 src/plugins/skills/woodcutting/woodcutting.plugin.ts diff --git a/src/engine/action/pipe/task/walk-to-object-plugin-task.ts b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts index 1229783fd..aa07e70ec 100644 --- a/src/engine/action/pipe/task/walk-to-object-plugin-task.ts +++ b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts @@ -4,6 +4,7 @@ import { Player } from '@engine/world/actor'; import { ObjectInteractionAction } from '../object-interaction.action'; import { ItemOnObjectAction } from '../item-on-object.action'; import { ActionHook } from '@engine/action/hook'; +import { Position } from '@engine/world'; /** * All actions supported by this plugin task. @@ -35,15 +36,25 @@ export class WalkToObjectPluginTask extends ActorL private data: ObjectActionData; constructor(plugins: ObjectActionHook[], player: Player, landscapeObject: LandscapeObject, data: ObjectActionData) { + const rendering = data.objectConfig?.rendering; + let sizeX = rendering?.sizeX || 1; + let sizeY = rendering?.sizeY || 1; + + // Get the object's facing direction (0-3 maps to WNES array) TODO: verify + const face = rendering?.face || 0; + + // If facing East or West, swap X and Y dimensions + if (face === 0 || face === 2) { // WEST or EAST + [sizeX, sizeY] = [sizeY, sizeX]; + } super( player, landscapeObject, // TODO (jkm) handle object size // TODO (jkm) pass orientation instead of size - 1, - 1, + sizeX, + sizeY, ); - this.plugins = plugins; this.data = data; } @@ -58,6 +69,14 @@ export class WalkToObjectPluginTask extends ActorL return; } + // Make the actor face the center of the object + const objectCenter = new Position( + landscapeObjectPosition.x + Math.floor((this.data.objectConfig?.rendering?.sizeX || 1) / 2), + landscapeObjectPosition.y + Math.floor((this.data.objectConfig?.rendering?.sizeY || 1) / 2), + landscapeObjectPosition.level + ); + this.actor.face(objectCenter); + this.plugins.forEach(plugin => { if (!plugin?.handler) return; diff --git a/src/engine/plugins/plugin.types.ts b/src/engine/plugins/plugin.types.ts index 11019b37f..641ba2caa 100644 --- a/src/engine/plugins/plugin.types.ts +++ b/src/engine/plugins/plugin.types.ts @@ -1,4 +1,4 @@ -import { ActionHook, ActionType } from '@engine/action'; +import { ActionType, ObjectInteractionAction } from '@engine/action'; import { Quest } from '@engine/world/actor/player/quest'; // Base hook type that all hook types must extend @@ -13,6 +13,7 @@ export interface ObjectInteractionHook extends BaseHook { objectIds: number[]; options?: string[]; walkTo?: boolean; + handler: (details: ObjectInteractionAction) => any; } // Button interaction hook type diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index 97a832326..4709216cc 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -1,5 +1,6 @@ import { Actor } from '@engine/world/actor/actor'; import { Player } from '@engine/world/actor/player'; +import { ActionTimer } from "@engine/world/actor/timing/action-timer"; /** * Represents different queue types for tick tasks. @@ -45,6 +46,11 @@ export interface RequestTickOptions { */ type?: QueueType; + /** + * Whether to use the global action timer that can be manipulated + * @default false + */ + useGlobalTimer?: boolean; } @@ -65,6 +71,8 @@ export interface TickTask { type: QueueType; /** Tick number when task was started */ startTick: number; + + useGlobalTimer?: boolean; } @@ -92,7 +100,7 @@ export class TickQueue { public currentTick: number = 0; /** List of queued tasks */ private tasks: TickTask[] = []; - + private actionTimer = new ActionTimer(); /** * Creates a new TickQueue for the given actor * @param actor The actor this queue belongs to @@ -148,7 +156,8 @@ export class TickQueue { public async requestTicks(options: RequestTickOptions): Promise { const { ticks, - type = QueueType.NORMAL + type = QueueType.NORMAL, + useGlobalTimer = false } = options; // Handle STRONG tasks entering queue @@ -168,6 +177,10 @@ export class TickQueue { this.actor.interfaceState.closeAllSlots(); } + if (useGlobalTimer) { + this.actionTimer.setTimer(ticks); + } + let resolveFunc: () => void; let rejectFunc: (reason?: any) => void; @@ -182,7 +195,8 @@ export class TickQueue { resolve: resolveFunc!, reject: rejectFunc!, type, - startTick: this.currentTick + startTick: this.currentTick, + useGlobalTimer }; this.tasks.push(task); @@ -201,7 +215,7 @@ export class TickQueue { public tick(): void { this.currentTick++; - + this.actionTimer.tick(); // Check if actor is delayed const isDelayed = this.actor.delayManager.isDelayed(); @@ -216,7 +230,7 @@ export class TickQueue { // 1. It's a SOFT task (these ignore delays) // 2. OR actor is not delayed and task can be processed if (task.type === QueueType.SOFT || (!isDelayed && this.canProcessTask(task))) { - if (this.currentTick >= task.startTick + task.ticks) { + if (this.shouldCompleteTask(task)) { // Handle modal interfaces for STRONG/SOFT tasks if (this.actor instanceof Player && (task.type === QueueType.STRONG || task.type === QueueType.SOFT)) { @@ -232,6 +246,19 @@ export class TickQueue { } while (processedTasks > 0 && this.tasks.length > 0); } + private shouldCompleteTask(task: TickTask): boolean { + const elapsed = this.currentTick - task.startTick; + if (elapsed < task.ticks) { + return false; + } + + if (task.useGlobalTimer) { + return !this.actionTimer.isActive(); + } + + return true; + } + /** * Checks if a task can be processed based on its type and current conditions * @param task The task to check diff --git a/src/engine/world/actor/timing/action-timer.ts b/src/engine/world/actor/timing/action-timer.ts new file mode 100644 index 000000000..0e0bf87ed --- /dev/null +++ b/src/engine/world/actor/timing/action-timer.ts @@ -0,0 +1,20 @@ +/** + * Manages the global action timer that can be manipulated + */ +export class ActionTimer { + private timer: number = 0; + + public tick(): void { + if (this.timer > 0) { + this.timer--; + } + } + + public setTimer(ticks: number): void { + this.timer = ticks; + } + + public isActive(): boolean { + return this.timer > 0; + } +} diff --git a/src/engine/world/config/harvestable-object.ts b/src/engine/world/config/harvestable-object.ts index f936432d2..b75635405 100644 --- a/src/engine/world/config/harvestable-object.ts +++ b/src/engine/world/config/harvestable-object.ts @@ -55,56 +55,6 @@ const RUNITE_OBJECTS: Map = new Map([ ...objectIds.default.runite.map((tree) => [tree.default, tree.empty]), ] as [number, number][]); -const NORMAL_OBJECTS: Map = new Map([ - ...objectIds.tree.normal.map((tree) => [tree.default, tree.stump]), - ...objectIds.tree.dead.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const ACHEY_OBJECTS: Map = new Map([ - ...objectIds.tree.archey.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const OAK_OBJECTS: Map = new Map([ - ...objectIds.tree.oak.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const WILLOW_OBJECTS: Map = new Map([ - ...objectIds.tree.willow.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const TEAK_OBJECTS: Map = new Map([ - ...objectIds.tree.teak.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const DRAMEN_OBJECTS: Map = new Map([ - ...objectIds.tree.dramen.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const MAPLE_OBJECTS: Map = new Map([ - ...objectIds.tree.maple.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const HOLLOW_OBJECTS: Map = new Map([ - ...objectIds.tree.hollow.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const MAHOGANY_OBJECTS: Map = new Map([ - ...objectIds.tree.mahogany.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const YEW_OBJECTS: Map = new Map([ - ...objectIds.tree.yew.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const MAGIC_OBJECTS: Map = new Map([ - ...objectIds.tree.magic.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - export enum Ore { CLAY, COPPER, @@ -247,120 +197,6 @@ const Ores: IHarvestable[] = [ } ]; -const Trees: IHarvestable[] = [ - { - objects: NORMAL_OBJECTS, - itemId: itemIds.logs.normal, - level: 1, - experience: 25, - respawnLow: 59, - respawnHigh: 98, - baseChance: 70, - break: 100 - }, - { - objects: ACHEY_OBJECTS, - itemId: itemIds.logs.achey, - level: 1, - experience: 25, - respawnLow: 59, - respawnHigh: 98, - baseChance: 70, - break: 100 - }, - { - objects: OAK_OBJECTS, - itemId: itemIds.logs.oak, - level: 15, - experience: 37.5, - respawnLow: 14, - respawnHigh: 14, - baseChance: 50, - break: 100 / 8 - }, - { - objects: WILLOW_OBJECTS, - itemId: itemIds.logs.willow, - level: 30, - experience: 67.5, - respawnLow: 14, - respawnHigh: 14, - baseChance: 30, - break: 100 / 8 - }, - { - objects: TEAK_OBJECTS, - itemId: itemIds.logs.teak, - level: 35, - experience: 85, - respawnLow: 15, - respawnHigh: 15, - baseChance: 0, - break: 100 / 8 - }, - - { - objects: DRAMEN_OBJECTS, - itemId: itemIds.logs.dramenbranch, - level: 36, - experience: 0, - respawnLow: 0, - respawnHigh: 0, - baseChance: 100, - break: 0 - }, - { - objects: MAPLE_OBJECTS, - itemId: itemIds.logs.maple, - level: 45, - experience: 100, - respawnLow: 59, - respawnHigh: 59, - baseChance: 0, - break: 100 / 8 - }, - { - objects: HOLLOW_OBJECTS, - itemId: itemIds.logs.bark, - level: 45, - experience: 82.5, - respawnLow: 43, - respawnHigh: 44, - baseChance: 0, - break: 100 / 8 - }, - { - objects: MAHOGANY_OBJECTS, - itemId: itemIds.logs.mahogany, - level: 50, - experience: 125, - respawnLow: 14, - respawnHigh: 14, - baseChance: -5, - break: 100 / 8 - }, - { - objects: YEW_OBJECTS, - itemId: itemIds.logs.yew, - level: 60, - experience: 175, - respawnLow: 99, - respawnHigh: 99, - baseChance: -15, - break: 100 / 8 - }, - { - objects: MAGIC_OBJECTS, - itemId: itemIds.logs.magic, - level: 75, - experience: 250, - respawnLow: 199, - respawnHigh: 199, - baseChance: -25, - break: 100 / 8 - }, -]; - export function getOre(ore: Ore): IHarvestable { return Ores[ore]; } @@ -369,9 +205,7 @@ export function getOreFromRock(id: number): IHarvestable { return Ores.find(ore => ore.objects.has(id)) as IHarvestable; } -export function getTreeFromHealthy(id: number): IHarvestable { - return Trees.find(tree => tree.objects.has(id)) as IHarvestable; -} + export function getOreFromDepletedRock(id: number): IHarvestable { return Ores.find(ore => { @@ -395,12 +229,3 @@ export function getAllOreIds(): number[] { return oreIds; } -export function getTreeIds(): number[] { - const treeIds: number[] = []; - for (const tree of Trees) { - for (const [healthy, expired] of tree.objects) { - treeIds.push(healthy); - } - } - return treeIds; -} diff --git a/src/engine/world/items/item-container.ts b/src/engine/world/items/item-container.ts index ed291a045..098a240f6 100644 --- a/src/engine/world/items/item-container.ts +++ b/src/engine/world/items/item-container.ts @@ -281,6 +281,10 @@ export class ItemContainer { return this.getFirstOpenSlot() !== -1; } + public isFull(): boolean { + return !this.hasSpace(); + } + public getOpenSlotCount(): number { let count = 0; for(let i = 0; i < this._size; i++) { diff --git a/src/engine/world/position.ts b/src/engine/world/position.ts index 7a8190e45..8ee4cfb6a 100644 --- a/src/engine/world/position.ts +++ b/src/engine/world/position.ts @@ -52,7 +52,9 @@ export class Position { public withinInteractionDistance(target: LandscapeObject | Position, minimumDistance?: number): boolean; public withinInteractionDistance(target: LandscapeObject | Position, minimumDistance: number = 1): boolean { if(target instanceof Position) { - return this.distanceBetween(target) <= minimumDistance; + const xDiff = Math.abs(this.x - target.x); + const yDiff = Math.abs(this.y - target.y); + return xDiff <= minimumDistance && yDiff <= minimumDistance; } else { const definition = filestore.configStore.objectStore.getObject(target.objectId); @@ -72,18 +74,23 @@ export class Position { height = 1; } - if(width === 1 && height === 1) { - return this.distanceBetween(new Position(occupantX, occupantY, target.level)) <= minimumDistance; - } else { - if(target.orientation === 1 || target.orientation === 3) { - const off = width; - width = height; - height = off; - } + // Handle orientation + if(target.orientation === 1 || target.orientation === 3) { + const off = width; + width = height; + height = off; + } + + // Check if we're adjacent to any part of the object + for(let x = occupantX; x < occupantX + width; x++) { + for(let y = occupantY; y < occupantY + height; y++) { + const xDiff = Math.abs(this.x - x); + const yDiff = Math.abs(this.y - y); - for(let x = occupantX; x < occupantX + width; x++) { - for(let y = occupantY; y < occupantY + height; y++) { - if(this.distanceBetween(new Position(x, y, target.level)) <= minimumDistance) { + // We're within interaction distance if we're 1 tile away + // but not standing on the object itself + if(xDiff <= minimumDistance && yDiff <= minimumDistance) { + if(!(xDiff === 0 && yDiff === 0)) { return true; } } diff --git a/src/plugins/skills/woodcutting/chance.ts b/src/plugins/skills/woodcutting/chance.ts deleted file mode 100644 index ef7a5f31c..000000000 --- a/src/plugins/skills/woodcutting/chance.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { randomBetween } from '@engine/util'; -import { IHarvestable } from '@engine/world/config'; - -/** - * Roll a random number between 0 and 255 and compare it to the percent needed to cut the tree. - * - * @param tree The tree to cut - * @param toolLevel The level of the axe being used - * @param woodcuttingLevel The player's woodcutting level - * - * @returns True if the tree was successfully cut, false otherwise - */ -export const canCut = ( - tree: IHarvestable, - toolLevel: number, - woodcuttingLevel: number -): boolean => { - const successChance = randomBetween(0, 255); - - const percentNeeded = - tree.baseChance + toolLevel + woodcuttingLevel; - return successChance <= percentNeeded; -}; diff --git a/src/plugins/skills/woodcutting/index.ts b/src/plugins/skills/woodcutting/index.ts deleted file mode 100644 index 12bd82cf8..000000000 --- a/src/plugins/skills/woodcutting/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - ObjectInteractionActionHook, -} from '@engine/action'; -import { getTreeIds } from '@engine/world/config/harvestable-object'; -import { runWoodcuttingTask } from './woodcutting-task'; - -/** - * Woodcutting plugin - * - * This uses the task system to schedule actions. - */ -export default { - pluginId: 'rs:woodcutting', - hooks: [ - /** - * "Chop down" / "chop" object interaction hook. - */ - { - type: 'object_interaction', - options: [ 'chop down', 'chop' ], - objectIds: getTreeIds(), - handler: ({ player, object }) => { - runWoodcuttingTask(player, object); - } - } as ObjectInteractionActionHook - ] -}; diff --git a/src/plugins/skills/woodcutting/woodcutting-task.ts b/src/plugins/skills/woodcutting/woodcutting-task.ts deleted file mode 100644 index d9615fbee..000000000 --- a/src/plugins/skills/woodcutting/woodcutting-task.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Skill } from '@engine/world/actor/skills'; -import { canInitiateHarvest } from '@engine/world/skill-util/harvest-skill'; -import { getTreeFromHealthy, IHarvestable } from '@engine/world/config/harvestable-object'; -import { randomBetween } from '@engine/util/num'; -import { colorText } from '@engine/util/strings'; -import { colors } from '@engine/util/colors'; -import { rollBirdsNestType } from '@engine/world/skill-util/harvest-roll'; -import { soundIds } from '@engine/world/config/sound-ids'; -import { findItem, findObject } from '@engine/config/config-handler'; -import { activeWorld } from '@engine/world'; -import { canCut } from './chance'; -import { ActorLandscapeObjectInteractionTask } from '@engine/task/impl'; -import { Player } from '@engine/world/actor'; -import { LandscapeObject } from '@runejs/filestore'; -import { logger } from '@runejs/common'; - -class WoodcuttingTask extends ActorLandscapeObjectInteractionTask { - /** - * The tree being cut down. - */ - private treeInfo: IHarvestable; - - /** - * The number of ticks that `execute` has been called inside this task. - */ - private elapsedTicks = 0; - - /** - * Create a new woodcutting task. - * - * @param player The player that is attempting to cut down the tree. - * @param landscapeObject The object that represents the tree. - * @param sizeX The size of the tree in x axis. - * @param sizeY The size of the tree in y axis. - */ - constructor( - player: Player, - landscapeObject: LandscapeObject, - sizeX: number, - sizeY: number - ) { - super( - player, - landscapeObject, - sizeX, - sizeY - ); - - if (!landscapeObject) { - this.stop(); - return; - } - - this.treeInfo = getTreeFromHealthy(landscapeObject.objectId); - if (!this.treeInfo) { - this.stop(); - return; - } - } - - /** - * Execute the main woodcutting task loop. This method is called every game tick until the task is completed. - * - * As this task extends {@link ActorLandscapeObjectInteractionTask}, it's important that the - * `super.execute` method is called at the start of this method. - * - * The base `execute` performs a number of checks that allow this task to function healthily. - */ - public execute(): void { - super.execute(); - - if (!this.isActive || !this.landscapeObject) { - return; - } - - // store the tick count before incrementing so we don't need to keep track of it in all the separate branches - const taskIteration = this.elapsedTicks++; - - const tool = canInitiateHarvest(this.actor, this.treeInfo, Skill.WOODCUTTING); - - if (!tool) { - this.stop(); - return; - } - - if(taskIteration === 0) { - this.actor.sendMessage('You swing your axe at the tree.'); - this.actor.face(this.landscapeObjectPosition); - this.actor.playAnimation(tool.animation); - // First tick / iteration should never proceed beyond this point. - return; - } - - // play a random axe sound at the correct time - if (taskIteration % 3 !== 0) { - const randomSoundIdx = Math.floor( - Math.random() * soundIds.axeSwing.length, - ); - this.actor.playSound(soundIds.axeSwing[randomSoundIdx], 7, 0); - } - - // roll for success - const succeeds = canCut( - this.treeInfo, - tool.level, - this.actor.skills.woodcutting.level, - ); - - if (!succeeds) { - this.actor.playAnimation(tool.animation); - - // Keep chopping. - return; - } - - const logItem = findObject(this.treeInfo.itemId); - - if(!logItem) { - logger.error(`Could not find log item with id ${this.treeInfo.itemId}`); - this.actor.sendMessage('Sorry, an error occurred. Please report this to a developer.'); - this.stop(); - return; - } - - const targetName = (logItem.name || '').toLowerCase(); - - // if player doesn't have space in inventory, stop the task - if(!this.actor.inventory.hasSpace()) { - this.actor.sendMessage(`Your inventory is too full to hold any more ${targetName}.`, true); - this.actor.playSound(soundIds.inventoryFull); - this.stop(); - return; - } - - const itemToAdd = this.treeInfo.itemId; - const roll = randomBetween(1, 256); - // roll for bird nest chance - if(roll === 1) { - this.actor.sendMessage(colorText(`A bird's nest falls out of the tree.`, colors.red)); - activeWorld.globalInstance.spawnWorldItem(rollBirdsNestType(), this.actor.position, - { owner: this.actor || null, expires: 300 }); - } else { // Standard log chopper - this.actor.sendMessage(`You manage to chop some ${targetName}.`); - this.actor.giveItem(itemToAdd); - } - - this.actor.skills.woodcutting.addExp(this.treeInfo.experience); - - // check if the tree should be broken - if(randomBetween(0, 100) <= this.treeInfo.break) { - // TODO (Jameskmonger) is this the correct sound? - this.actor.playSound(soundIds.oreDepeleted); - - const brokenTreeId = this.treeInfo.objects.get(this.landscapeObject.objectId); - - if (brokenTreeId !== undefined) { - this.actor.instance.replaceGameObject(brokenTreeId, - this.landscapeObject, randomBetween(this.treeInfo.respawnLow, this.treeInfo.respawnHigh)); - } else { - logger.error(`Could not find broken tree id for tree id ${this.landscapeObject.objectId}`); - } - - this.stop(); - } - } - - /** - * This method is called when the task stops. - */ - public onStop(): void { - super.onStop(); - - this.actor.stopAnimation(); - } -} - -export function runWoodcuttingTask(player: Player, landscapeObject: LandscapeObject): void { - const objectConfig = findObject(landscapeObject.objectId); - - if (!objectConfig) { - logger.warn(`Player ${player.username} attempted to run a woodcutting task on an invalid object (id: ${landscapeObject.objectId})`); - return; - } - - const sizeX = objectConfig.rendering.sizeX; - const sizeY = objectConfig.rendering.sizeY; - - player.enqueueTask(WoodcuttingTask, [ landscapeObject, sizeX, sizeY ]); -} diff --git a/src/plugins/skills/woodcutting/woodcutting.constants.ts b/src/plugins/skills/woodcutting/woodcutting.constants.ts new file mode 100644 index 000000000..b2dd0a930 --- /dev/null +++ b/src/plugins/skills/woodcutting/woodcutting.constants.ts @@ -0,0 +1,206 @@ +import { IHarvestable, itemIds, objectIds, soundIds } from '@engine/world/config'; + +export const WOODCUTTING_SOUNDS = { + CHOP: soundIds.axeSwing, // Array of [88, 89, 90] + TREE_DEPLETED: soundIds.oreDepeleted // 3600 +}; + +interface AxeData { + level: number; + animationId: number; + bonus: number; +} + + + +export const AXES = new Map([ + [itemIds.axes.runite, { level: 41, animationId: 867, bonus: 8 }], + [itemIds.axes.adamantite, { level: 31, animationId: 869, bonus: 7 }], + [itemIds.axes.mithril, { level: 21, animationId: 871, bonus: 6 }], + // [itemIds.axes.black, { level: 11, animationId: 873, bonus: 5 }], + [itemIds.axes.steel, { level: 6, animationId: 875, bonus: 4 }], + [itemIds.axes.iron, { level: 1, animationId: 877, bonus: 3 }], + [itemIds.axes.bronze, { level: 1, animationId: 879, bonus: 2 }] +]); + + + +const NORMAL_OBJECTS: Map = new Map([ + ...objectIds.tree.normal.map((tree) => [tree.default, tree.stump]), + ...objectIds.tree.dead.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + +const ACHEY_OBJECTS: Map = new Map([ + ...objectIds.tree.archey.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + +const OAK_OBJECTS: Map = new Map([ + ...objectIds.tree.oak.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + + +const WILLOW_OBJECTS: Map = new Map([ + ...objectIds.tree.willow.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + + +const TEAK_OBJECTS: Map = new Map([ + ...objectIds.tree.teak.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + +const DRAMEN_OBJECTS: Map = new Map([ + ...objectIds.tree.dramen.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + + +const MAPLE_OBJECTS: Map = new Map([ + ...objectIds.tree.maple.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + + +const HOLLOW_OBJECTS: Map = new Map([ + ...objectIds.tree.hollow.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + +const MAHOGANY_OBJECTS: Map = new Map([ + ...objectIds.tree.mahogany.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + + +const YEW_OBJECTS: Map = new Map([ + ...objectIds.tree.yew.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + +const MAGIC_OBJECTS: Map = new Map([ + ...objectIds.tree.magic.map((tree) => [tree.default, tree.stump]), +] as [number, number][]); + + + +const Trees: IHarvestable[] = [ + { + objects: NORMAL_OBJECTS, + itemId: itemIds.logs.normal, + level: 1, + experience: 25, + respawnLow: 59, + respawnHigh: 98, + baseChance: 70, + break: 100.0 + }, + { + objects: ACHEY_OBJECTS, + itemId: itemIds.logs.achey, + level: 1, + experience: 25, + respawnLow: 59, + respawnHigh: 98, + baseChance: 70, + break: 100.0 + }, + { + objects: OAK_OBJECTS, + itemId: itemIds.logs.oak, + level: 15, + experience: 37.5, + respawnLow: 14, + respawnHigh: 14, + baseChance: 50, + break: 100 / 8.0 + }, + { + objects: WILLOW_OBJECTS, + itemId: itemIds.logs.willow, + level: 30, + experience: 67.5, + respawnLow: 14, + respawnHigh: 14, + baseChance: 30, + break: 100 / 8.0 + }, + { + objects: TEAK_OBJECTS, + itemId: itemIds.logs.teak, + level: 35, + experience: 85, + respawnLow: 15, + respawnHigh: 15, + baseChance: 0, + break: 100 / 8.0 + }, + + { + objects: DRAMEN_OBJECTS, + itemId: itemIds.logs.dramenbranch, + level: 36, + experience: 0, + respawnLow: 0, + respawnHigh: 0, + baseChance: 100, + break: 0 + }, + { + objects: MAPLE_OBJECTS, + itemId: itemIds.logs.maple, + level: 45, + experience: 100, + respawnLow: 59, + respawnHigh: 59, + baseChance: 0, + break: 100 / 8.0 + }, + { + objects: HOLLOW_OBJECTS, + itemId: itemIds.logs.bark, + level: 45, + experience: 82.5, + respawnLow: 43, + respawnHigh: 44, + baseChance: 0, + break: 100 / 8.0 + }, + { + objects: MAHOGANY_OBJECTS, + itemId: itemIds.logs.mahogany, + level: 50, + experience: 125, + respawnLow: 14, + respawnHigh: 14, + baseChance: -5, + break: 100 / 8.0 + }, + { + objects: YEW_OBJECTS, + itemId: itemIds.logs.yew, + level: 60, + experience: 175, + respawnLow: 99, + respawnHigh: 99, + baseChance: -15, + break: 100 / 8.0 + }, + { + objects: MAGIC_OBJECTS, + itemId: itemIds.logs.magic, + level: 75, + experience: 250, + respawnLow: 199, + respawnHigh: 199, + baseChance: -25, + break: 100 / 8.0 + }, +]; + + +export function getTreeIds(): number[] { + const treeIds: number[] = []; + for (const tree of Trees) { + for (const [healthy, expired] of tree.objects) { + treeIds.push(healthy); + } + } + return treeIds; +} +export function getTreeFromHealthy(id: number): IHarvestable { + return Trees.find(tree => tree.objects.has(id)) as IHarvestable; +} diff --git a/src/plugins/skills/woodcutting/woodcutting.plugin.ts b/src/plugins/skills/woodcutting/woodcutting.plugin.ts new file mode 100644 index 000000000..ed2d54f85 --- /dev/null +++ b/src/plugins/skills/woodcutting/woodcutting.plugin.ts @@ -0,0 +1,168 @@ +import { Player, Skill } from '@engine/world/actor'; +import { LandscapeObject } from '@runejs/filestore'; +import { findItem } from '@engine/config'; +import { QueueType } from '@engine/world/actor/tick-queue'; +import { + AXES, + getTreeFromHealthy, + getTreeIds, + WOODCUTTING_SOUNDS +} from '@plugins/skills/woodcutting/woodcutting.constants'; +import { randomBetween } from '@engine/util'; +import { ContentPlugin } from '@engine/plugins/plugin.types'; +import { ObjectInteractionAction } from '@engine/action'; + + +const getBestAxe = (player: Player): number | null => { + const availableAxes = [...AXES.entries()] + .filter(([axeId, data]) => + player.hasItemInInventory(axeId) || + player.isItemEquipped(axeId)).filter(([axeId, data]) => player.skills.hasLevel(Skill.WOODCUTTING, data.level)) + .sort(([, a], [, b]) => b.bonus - a.bonus); + + if (availableAxes.length === 0) { + player.sendMessage('You do not have an axe which you have the woodcutting level to use.'); + return null; + } + + return availableAxes[0][0]; +}; + +const handleSoundCycle = (player: Player, startTick: number): void => { + const currentTick = player.tickQueue.currentTick; + const relativeTick = (currentTick - startTick) % 3; + const chopSound = WOODCUTTING_SOUNDS.CHOP[ + Math.floor(Math.random() * WOODCUTTING_SOUNDS.CHOP.length) + ]; + const volumes = [8, 0, 18]; // third, second, first chop + player.playSound(chopSound, volumes[relativeTick]); +}; + +const checkTreeDepletion = (player: Player, tree: LandscapeObject): boolean => { + const treeData = getTreeFromHealthy(tree.objectId); + if (!treeData) return true; + + const depletionChance = treeData.break / 100; + + if (Math.random() < depletionChance) { + const respawnTicks = randomBetween(treeData.respawnLow, treeData.respawnHigh) + // Scale by player count in area + // const scaledTicks = Math.ceil(respawnTicks * (1 + player.region.playerCount * 0.1)); + const scaledTicks = respawnTicks; + + player.playSound(WOODCUTTING_SOUNDS.TREE_DEPLETED, 10); + + const brokenId = treeData.objects.get(tree.objectId); + + if(brokenId) { + // await tree.transform(tree.nextStage, scaledTicks); + player.instance.replaceGameObject(brokenId, + tree, scaledTicks); + } + + return true; + } + return false; +}; + +const calculateSuccess = (player: Player, tree: LandscapeObject, axe: number): boolean => { + const treeData = getTreeFromHealthy(tree.objectId); + const axeData = AXES.get(axe); + if (!treeData || !axeData) return false; + + const playerLevel = player.skills.getLevel('woodcutting'); + // const low = treeData.baseChance + axeData.bonus; + const high = treeData.baseChance + axeData.bonus; + + return Math.random() * high < (playerLevel + axeData.bonus); +}; + +const startWoodcutting = async (details: ObjectInteractionAction): Promise => { + const { player, object: tree } = details; + + const treeData = getTreeFromHealthy(tree.objectId); + if (!treeData) return; + + // Initial requirements check + if (player.skills.getLevel('woodcutting') < treeData.level) { + player.sendMessage(`You need a Woodcutting level of ${treeData.level} to chop this tree.`); + return; + } + + const axe = getBestAxe(player); + if (!axe) return; + + // Initial setup + const startTick = player.tickQueue.currentTick; + player.sendMessage('You swing your axe at the tree.'); + + const chopTree = async (): Promise => { + // Check if we can still chop + if (player.inventory.isFull()) { + player.sendMessage(`Your inventory is too full to hold any more ${findItem(treeData.itemId)?.name.toLowerCase()}.`); + return; + } + + // Play animation every 4 ticks + if ((player.tickQueue.currentTick - startTick) % 2 === 0) { + const axeData = AXES.get(axe); + if (axeData) player.playAnimation(axeData.animationId); + } + + // Handle sound cycle every 3 ticks + handleSoundCycle(player, startTick); + + try { + // Wait for woodcutting timer (3 ticks normally, can be manipulated) + await player.tickQueue.requestTicks({ + ticks: 3, + type: QueueType.WEAK, + useGlobalTimer: true + }); + + // Check for success + if (calculateSuccess(player, tree, axe)) { + // Give logs and xp + if (player.giveItem(treeData.itemId)) { + player.skills.addExp('woodcutting', treeData.experience); + player.sendMessage(`You get some ${findItem(treeData.itemId)?.name.toLowerCase()}.`); + } + + // Check for depletion + if (checkTreeDepletion(player, tree)) { + player.playAnimation(null); + return; + } + } + + // Recursively continue chopping + await chopTree(); + + } catch (error) { + // Handle interruption + player.playAnimation(null); + } + }; + + // Start the chopping cycle + await chopTree(); +}; + +export default { + pluginId: 'rs:woodcutting', + hooks: [ + { + type: 'object_interaction', + objectIds: getTreeIds(), + options: ['chop down'], + handler: async (details) => startWoodcutting(details), + walkTo: true + }, + // { + // type: 'item_on_object', + // objectIds: [...getTreeIds()], + // itemIds: [...AXES.keys()], + // handler: async ({ player, object }) => startWoodcutting(player, object) + // } + ] +}; From 457e92ac3e666140d92a40af15ae0c8a7939aada Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Fri, 31 Jan 2025 20:03:13 +0100 Subject: [PATCH 07/13] adjust wc for develop branch --- src/engine/world/actor/tick-queue.ts | 4 +- src/engine/world/config/harvestable-object.ts | 149 +----------------- src/plugins/skills/mining/mining-task.ts | 2 +- .../woodcutting/woodcutting.constants.ts | 102 +++++++++--- .../skills/woodcutting/woodcutting.plugin.ts | 11 +- 5 files changed, 90 insertions(+), 178 deletions(-) diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index 4709216cc..0114dc069 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -1,6 +1,6 @@ import { Actor } from '@engine/world/actor/actor'; import { Player } from '@engine/world/actor/player'; -import { ActionTimer } from "@engine/world/actor/timing/action-timer"; +import { ActionTimer } from '@engine/world/actor/timing/action-timer'; /** * Represents different queue types for tick tasks. @@ -192,7 +192,9 @@ export class TickQueue { const task: TickTask = { ticks, promise, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion resolve: resolveFunc!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion reject: rejectFunc!, type, startTick: this.currentTick, diff --git a/src/engine/world/config/harvestable-object.ts b/src/engine/world/config/harvestable-object.ts index 101ad1e1c..134f95423 100644 --- a/src/engine/world/config/harvestable-object.ts +++ b/src/engine/world/config/harvestable-object.ts @@ -1,7 +1,7 @@ import { objectIds } from '@engine/world/config/object-ids'; import { randomBetween } from '@engine/util'; -interface WeightedItem { +export interface WeightedItem { itemConfigId: string; weight: number; } @@ -276,139 +276,6 @@ const Ores: IHarvestable[] = [ } ]; -const Trees: IHarvestable[] = [ - { - objects: NORMAL_OBJECTS, - items: 'rs:logs', - level: 1, - experience: 25, - respawnLow: 59, - respawnHigh: 98, - baseChance: 70, - break: 100 - }, - { - objects: ACHEY_OBJECTS, - items: 'rs:achey_logs', - level: 1, - experience: 25, - respawnLow: 59, - respawnHigh: 98, - baseChance: 70, - break: 100 - }, - { - objects: OAK_OBJECTS, - items: 'rs:oak_logs', - level: 15, - experience: 37.5, - respawnLow: 14, - respawnHigh: 14, - baseChance: 50, - break: 100 / 8 - }, - { - objects: WILLOW_OBJECTS, - items: 'rs:willow_logs', - level: 30, - experience: 67.5, - respawnLow: 14, - respawnHigh: 14, - baseChance: 30, - break: 100 / 8 - }, - { - objects: TEAK_OBJECTS, - items: 'rs:teak_logs', - level: 35, - experience: 85, - respawnLow: 15, - respawnHigh: 15, - baseChance: 0, - break: 100 / 8 - }, - { - objects: DRAMEN_OBJECTS, - items: 'rs:dramen_branch', // You'll need to add this to logs.json - level: 36, - experience: 0, - respawnLow: 0, - respawnHigh: 0, - baseChance: 100, - break: 0 - }, - { - objects: MAPLE_OBJECTS, - items: 'rs:maple_logs', - level: 45, - experience: 100, - respawnLow: 59, - respawnHigh: 59, - baseChance: 0, - break: 100 / 8 - }, - { - objects: HOLLOW_OBJECTS, - items: 'rs:bark', // You'll need to add this to logs.json - level: 45, - experience: 82.5, - respawnLow: 43, - respawnHigh: 44, - baseChance: 0, - break: 100 / 8 - }, - { - objects: MAHOGANY_OBJECTS, - items: 'rs:mahogany_logs', - level: 50, - experience: 125, - respawnLow: 14, - respawnHigh: 14, - baseChance: -5, - break: 100 / 8 - }, - { - objects: YEW_OBJECTS, - items: 'rs:yew_logs', - level: 60, - experience: 175, - respawnLow: 99, - respawnHigh: 99, - baseChance: -15, - break: 100 / 8 - }, - { - objects: MAGIC_OBJECTS, - items: 'rs:magic_logs', - level: 75, - experience: 250, - respawnLow: 199, - respawnHigh: 199, - baseChance: -25, - break: 100 / 8 - }, - { - objects: DRAMEN_OBJECTS, - items: 'rs:dramen_branch', - level: 36, - experience: 0, - respawnLow: 0, - respawnHigh: 0, - baseChance: 100, - break: 0 - }, - { - objects: HOLLOW_OBJECTS, - items: 'rs:bark', - level: 45, - experience: 82.5, - respawnLow: 43, - respawnHigh: 44, - baseChance: 0, - break: 100 / 8 - }, -]; - export function getOre(ore: Ore): IHarvestable { return Ores[ore]; } @@ -417,10 +284,6 @@ export function getOreFromRock(id: number): IHarvestable { return Ores.find(ore => ore.objects.has(id)) as IHarvestable; } -export function getTreeFromHealthy(id: number): IHarvestable { - return Trees.find(tree => tree.objects.has(id)) as IHarvestable; -} - export function getOreFromDepletedRock(id: number): IHarvestable { return Ores.find(ore => { for (const [rock, expired] of ore.objects) { @@ -442,13 +305,3 @@ export function getAllOreIds(): number[] { } return oreIds; } - -export function getTreeIds(): number[] { - const treeIds: number[] = []; - for (const tree of Trees) { - for (const [healthy, expired] of tree.objects) { - treeIds.push(healthy); - } - } - return treeIds; -} diff --git a/src/plugins/skills/mining/mining-task.ts b/src/plugins/skills/mining/mining-task.ts index b9372a335..acb1c6402 100644 --- a/src/plugins/skills/mining/mining-task.ts +++ b/src/plugins/skills/mining/mining-task.ts @@ -61,7 +61,7 @@ export class MiningTask extends ActorLandscapeObjectInteractionTask { return itemConfig.key.startsWith('rs:amulet_of_glory:charged_'); } - public execute(): void { + public onObjectReached(): void { const taskIteration = this.elapsedTicks++; // This will be null if the player is not in range of the object. diff --git a/src/plugins/skills/woodcutting/woodcutting.constants.ts b/src/plugins/skills/woodcutting/woodcutting.constants.ts index b2dd0a930..34d877d46 100644 --- a/src/plugins/skills/woodcutting/woodcutting.constants.ts +++ b/src/plugins/skills/woodcutting/woodcutting.constants.ts @@ -1,4 +1,5 @@ -import { IHarvestable, itemIds, objectIds, soundIds } from '@engine/world/config'; +import { IHarvestable, itemIds, objectIds, soundIds, WeightedItem } from '@engine/world/config'; +import { randomBetween } from '@engine/util'; export const WOODCUTTING_SOUNDS = { CHOP: soundIds.axeSwing, // Array of [88, 89, 90] @@ -77,61 +78,61 @@ const MAGIC_OBJECTS: Map = new Map([ + const Trees: IHarvestable[] = [ { objects: NORMAL_OBJECTS, - itemId: itemIds.logs.normal, + items: 'rs:logs', level: 1, experience: 25, respawnLow: 59, respawnHigh: 98, baseChance: 70, - break: 100.0 + break: 100 }, { objects: ACHEY_OBJECTS, - itemId: itemIds.logs.achey, + items: 'rs:achey_logs', level: 1, experience: 25, respawnLow: 59, respawnHigh: 98, baseChance: 70, - break: 100.0 + break: 100 }, { objects: OAK_OBJECTS, - itemId: itemIds.logs.oak, + items: 'rs:oak_logs', level: 15, experience: 37.5, respawnLow: 14, respawnHigh: 14, baseChance: 50, - break: 100 / 8.0 + break: 100 / 8 }, { objects: WILLOW_OBJECTS, - itemId: itemIds.logs.willow, + items: 'rs:willow_logs', level: 30, experience: 67.5, respawnLow: 14, respawnHigh: 14, baseChance: 30, - break: 100 / 8.0 + break: 100 / 8 }, { objects: TEAK_OBJECTS, - itemId: itemIds.logs.teak, + items: 'rs:teak_logs', level: 35, experience: 85, respawnLow: 15, respawnHigh: 15, baseChance: 0, - break: 100 / 8.0 + break: 100 / 8 }, - { objects: DRAMEN_OBJECTS, - itemId: itemIds.logs.dramenbranch, + items: 'rs:dramen_branch', // You'll need to add this to logs.json level: 36, experience: 0, respawnLow: 0, @@ -141,53 +142,73 @@ const Trees: IHarvestable[] = [ }, { objects: MAPLE_OBJECTS, - itemId: itemIds.logs.maple, + items: 'rs:maple_logs', level: 45, experience: 100, respawnLow: 59, respawnHigh: 59, baseChance: 0, - break: 100 / 8.0 + break: 100 / 8 }, { objects: HOLLOW_OBJECTS, - itemId: itemIds.logs.bark, + items: 'rs:bark', // You'll need to add this to logs.json level: 45, experience: 82.5, respawnLow: 43, respawnHigh: 44, baseChance: 0, - break: 100 / 8.0 + break: 100 / 8 }, { objects: MAHOGANY_OBJECTS, - itemId: itemIds.logs.mahogany, + items: 'rs:mahogany_logs', level: 50, experience: 125, respawnLow: 14, respawnHigh: 14, baseChance: -5, - break: 100 / 8.0 + break: 100 / 8 }, { objects: YEW_OBJECTS, - itemId: itemIds.logs.yew, + items: 'rs:yew_logs', level: 60, experience: 175, respawnLow: 99, respawnHigh: 99, baseChance: -15, - break: 100 / 8.0 + break: 100 / 8 }, { objects: MAGIC_OBJECTS, - itemId: itemIds.logs.magic, + items: 'rs:magic_logs', level: 75, experience: 250, respawnLow: 199, respawnHigh: 199, baseChance: -25, - break: 100 / 8.0 + break: 100 / 8 + }, + { + objects: DRAMEN_OBJECTS, + items: 'rs:dramen_branch', + level: 36, + experience: 0, + respawnLow: 0, + respawnHigh: 0, + baseChance: 100, + break: 0 + }, + { + objects: HOLLOW_OBJECTS, + items: 'rs:bark', + level: 45, + experience: 82.5, + respawnLow: 43, + respawnHigh: 44, + baseChance: 0, + break: 100 / 8 }, ]; @@ -204,3 +225,38 @@ export function getTreeIds(): number[] { export function getTreeFromHealthy(id: number): IHarvestable { return Trees.find(tree => tree.objects.has(id)) as IHarvestable; } + +export function selectWeightedItem(items:string | WeightedItem[]): string { + if(typeof items === 'string') { + return items; + } + const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); + let random = randomBetween(1, totalWeight); + + for (const item of items) { + random -= item.weight; + if (random <= 0) { + return item.itemConfigId; + } + } + + return items[0].itemConfigId; // Fallback to first item +} + + +export function getPrimaryItem(items:string | WeightedItem[]): string { + if(typeof items === 'string') { + return items; + } + const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); + let random = randomBetween(1, totalWeight); + + for (const item of items) { + random -= item.weight; + if (random <= 0) { + return item.itemConfigId; + } + } + + return items[0].itemConfigId; // Fallback to first item +} diff --git a/src/plugins/skills/woodcutting/woodcutting.plugin.ts b/src/plugins/skills/woodcutting/woodcutting.plugin.ts index ed2d54f85..8000cd0cf 100644 --- a/src/plugins/skills/woodcutting/woodcutting.plugin.ts +++ b/src/plugins/skills/woodcutting/woodcutting.plugin.ts @@ -3,9 +3,9 @@ import { LandscapeObject } from '@runejs/filestore'; import { findItem } from '@engine/config'; import { QueueType } from '@engine/world/actor/tick-queue'; import { - AXES, + AXES, getPrimaryItem, getTreeFromHealthy, - getTreeIds, + getTreeIds, selectWeightedItem, WOODCUTTING_SOUNDS } from '@plugins/skills/woodcutting/woodcutting.constants'; import { randomBetween } from '@engine/util'; @@ -99,7 +99,7 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise const chopTree = async (): Promise => { // Check if we can still chop if (player.inventory.isFull()) { - player.sendMessage(`Your inventory is too full to hold any more ${findItem(treeData.itemId)?.name.toLowerCase()}.`); + player.sendMessage(`Your inventory is too full to hold any more ${findItem(getPrimaryItem(treeData.items))?.name.toLowerCase()}.`); return; } @@ -122,10 +122,11 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise // Check for success if (calculateSuccess(player, tree, axe)) { + const loot = selectWeightedItem(treeData.items); // Give logs and xp - if (player.giveItem(treeData.itemId)) { + if (player.giveItem(loot)) { player.skills.addExp('woodcutting', treeData.experience); - player.sendMessage(`You get some ${findItem(treeData.itemId)?.name.toLowerCase()}.`); + player.sendMessage(`You get some ${findItem(loot)?.name.toLowerCase()}.`); } // Check for depletion From 83c87100e0cd1770caac5d668de6c69b11612ead Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Fri, 31 Jan 2025 20:11:39 +0100 Subject: [PATCH 08/13] some typechecking fixes --- .../pipe/task/walk-to-actor-plugin-task.ts | 4 ++-- src/engine/world/actor/actor.ts | 18 +++++++++--------- src/plugins/npcs/lumbridge/bob.plugin.ts | 3 +-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/engine/action/pipe/task/walk-to-actor-plugin-task.ts b/src/engine/action/pipe/task/walk-to-actor-plugin-task.ts index 838c69684..394e05b02 100644 --- a/src/engine/action/pipe/task/walk-to-actor-plugin-task.ts +++ b/src/engine/action/pipe/task/walk-to-actor-plugin-task.ts @@ -53,9 +53,9 @@ export class WalkToActorPluginTask { // call super to manage waiting for the movement to complete - super.execute(); + await super.execute(); // check if the player has arrived yet const other = this.other; diff --git a/src/engine/world/actor/actor.ts b/src/engine/world/actor/actor.ts index 8f6215ccb..b8295f2fa 100644 --- a/src/engine/world/actor/actor.ts +++ b/src/engine/world/actor/actor.ts @@ -161,7 +161,7 @@ export abstract class Actor { }; } - public async moveBehind(target: Actor): Promise { + public moveBehind(target: Actor): boolean { if (this.position.level !== target.position.level) { return false; } @@ -179,7 +179,7 @@ export abstract class Actor { ignoreDestination = false; } - await this.pathfinding.walkTo(desiredPosition, { + this.pathfinding.walkTo(desiredPosition, { pathingSearchRadius: distance + 2, ignoreDestination }); @@ -187,7 +187,7 @@ export abstract class Actor { return true; } - public async moveTo(target: Actor): Promise { + public moveTo(target: Actor): boolean { if (this.position.level !== target.position.level) { return false; } @@ -198,7 +198,7 @@ export abstract class Actor { return false; } - await this.pathfinding.walkTo(target.position, { + this.pathfinding.walkTo(target.position, { pathingSearchRadius: distance + 2, ignoreDestination: true }); @@ -228,9 +228,9 @@ export abstract class Actor { }); } - public async walkTo(target: Actor): Promise; - public async walkTo(position: Position): Promise; - public async walkTo(target: Actor | Position): Promise { + public walkTo(target: Actor): boolean; + public walkTo(position: Position): boolean; + public walkTo(target: Actor | Position): boolean { const desiredPosition = target instanceof Position ? target : target.position; const distance = Math.floor(this.position.distanceBetween(desiredPosition)); @@ -245,7 +245,7 @@ export abstract class Actor { return false; } - await this.pathfinding.walkTo(desiredPosition, { + this.pathfinding.walkTo(desiredPosition, { pathingSearchRadius: distance + 2, ignoreDestination: true }); @@ -507,8 +507,8 @@ export abstract class Actor { */ public async requestTickDelay(ticks: number, options: Omit = {}): Promise { return this.tickQueue.requestTicks({ + ...options, ticks, - inheritCooldown: options.inheritCooldown }); } diff --git a/src/plugins/npcs/lumbridge/bob.plugin.ts b/src/plugins/npcs/lumbridge/bob.plugin.ts index 2d866eae3..42e64fb1d 100644 --- a/src/plugins/npcs/lumbridge/bob.plugin.ts +++ b/src/plugins/npcs/lumbridge/bob.plugin.ts @@ -1,4 +1,3 @@ -import { ContentPlugin } from '@engine/plugins'; import { NpcInteractionActionHook } from '@engine/action'; import { findShop } from '@engine/config'; @@ -11,7 +10,7 @@ const bobHook: NpcInteractionActionHook = { handler: ({ player }) => findShop('rs:lumbridge_bobs_axes')?.open(player) }; -const bobPlugin: ContentPlugin = { +const bobPlugin = { pluginId: 'rs:bob', hooks: [ bobHook ] } From c761a9e95732a4c2b6b9668ed2fdf7b97a18fd18 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Fri, 31 Jan 2025 20:20:34 +0100 Subject: [PATCH 09/13] enable item_on_object --- src/plugins/skills/woodcutting/woodcutting.plugin.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/skills/woodcutting/woodcutting.plugin.ts b/src/plugins/skills/woodcutting/woodcutting.plugin.ts index 8000cd0cf..d3966a36d 100644 --- a/src/plugins/skills/woodcutting/woodcutting.plugin.ts +++ b/src/plugins/skills/woodcutting/woodcutting.plugin.ts @@ -159,11 +159,11 @@ export default { handler: async (details) => startWoodcutting(details), walkTo: true }, - // { - // type: 'item_on_object', - // objectIds: [...getTreeIds()], - // itemIds: [...AXES.keys()], - // handler: async ({ player, object }) => startWoodcutting(player, object) - // } + { + type: 'item_on_object', + objectIds: getTreeIds(), + itemIds: [...AXES.keys()], + handler: async (details) => startWoodcutting(details) + } ] }; From 1247d82106c29ecb0edabf1eaaf5d561524479e5 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Sat, 1 Feb 2025 21:39:01 +0100 Subject: [PATCH 10/13] compat biome --- .../pipe/task/walk-to-object-plugin-task.ts | 8 +- src/engine/plugins/plugin.types.ts | 10 +- .../task/impl/actor-actor-interaction-task.ts | 9 +- src/engine/world/actor/tick-queue.ts | 23 +-- src/engine/world/actor/walking-queue.ts | 32 ++-- .../skills/crafting/spinning-wheel.plugin.ts | 43 ++---- .../woodcutting/woodcutting.constants.ts | 140 +++++++++--------- .../skills/woodcutting/woodcutting.plugin.ts | 61 ++++---- 8 files changed, 156 insertions(+), 170 deletions(-) diff --git a/src/engine/action/pipe/task/walk-to-object-plugin-task.ts b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts index 0f4e02202..9307eb126 100644 --- a/src/engine/action/pipe/task/walk-to-object-plugin-task.ts +++ b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts @@ -3,8 +3,8 @@ import type { ItemOnObjectAction } from '@engine/action/pipe/item-on-object.acti import type { ObjectInteractionAction } from '@engine/action/pipe/object-interaction.action'; import { ActorLandscapeObjectInteractionTask } from '@engine/task/impl/actor-landscape-object-interaction-task'; import type { Player } from '@engine/world/actor/player/player'; +import { Position } from '@engine/world/position'; import type { LandscapeObject } from '@runejs/filestore'; -import { Position } from '@engine/world'; /** * All actions supported by this plugin task. @@ -44,7 +44,8 @@ export class WalkToObjectPluginTask extends ActorL const face = rendering?.face || 0; // If facing East or West, swap X and Y dimensions - if (face === 0 || face === 2) { // WEST or EAST + if (face === 0 || face === 2) { + // WEST or EAST [sizeX, sizeY] = [sizeY, sizeX]; } super( @@ -59,7 +60,6 @@ export class WalkToObjectPluginTask extends ActorL this.data = data; } - protected onObjectReached(): void { const landscapeObject = this.landscapeObject; const landscapeObjectPosition = this.landscapeObjectPosition; @@ -73,7 +73,7 @@ export class WalkToObjectPluginTask extends ActorL const objectCenter = new Position( landscapeObjectPosition.x + Math.floor((this.data.objectConfig?.rendering?.sizeX || 1) / 2), landscapeObjectPosition.y + Math.floor((this.data.objectConfig?.rendering?.sizeY || 1) / 2), - landscapeObjectPosition.level + landscapeObjectPosition.level, ); this.actor.face(objectCenter); diff --git a/src/engine/plugins/plugin.types.ts b/src/engine/plugins/plugin.types.ts index 641ba2caa..fc1280ac5 100644 --- a/src/engine/plugins/plugin.types.ts +++ b/src/engine/plugins/plugin.types.ts @@ -1,4 +1,5 @@ -import { ActionType, ObjectInteractionAction } from '@engine/action'; +import { ActionType } from '@engine/action/action-pipeline'; +import { ObjectInteractionAction } from '@engine/action/pipe/object-interaction.action'; import { Quest } from '@engine/world/actor/player/quest'; // Base hook type that all hook types must extend @@ -43,7 +44,7 @@ export interface NpcInteractionHook extends BaseHook { export interface ItemInteractionHook extends BaseHook { type: 'item_interaction'; itemIds?: number | number[]; - widgets?: { widgetId: number, containerId: number } | { widgetId: number, containerId: number }[]; + widgets?: { widgetId: number; containerId: number } | { widgetId: number; containerId: number }[]; options?: string | string[]; } @@ -73,7 +74,7 @@ export interface ItemOnPlayerHook extends BaseHook { // Item-on-item hook type export interface ItemOnItemHook extends BaseHook { type: 'item_on_item'; - items: { item1: number, item2?: number }[]; + items: { item1: number; item2?: number }[]; } // Player/NPC init hook type @@ -131,7 +132,7 @@ export interface MoveItemHook extends BaseHook { // Item on world item hook type export interface ItemOnWorldItemHook extends BaseHook { type: 'item_on_world_item'; - items: { item?: number, worldItem?: number }[]; + items: { item?: number; worldItem?: number }[]; } // Spawned item interaction hook type @@ -168,7 +169,6 @@ export interface PrayerHook extends BaseHook { prayers?: number | number[]; } - // Union of all possible hook types export type PluginHook = | ObjectInteractionHook diff --git a/src/engine/task/impl/actor-actor-interaction-task.ts b/src/engine/task/impl/actor-actor-interaction-task.ts index 87d5f1b72..5b0c2ab7c 100644 --- a/src/engine/task/impl/actor-actor-interaction-task.ts +++ b/src/engine/task/impl/actor-actor-interaction-task.ts @@ -1,6 +1,6 @@ -import { Actor } from '@engine/world/actor'; +import { Task } from '@engine/task/task'; +import { Actor } from '@engine/world/actor/actor'; import { ActorWalkToTask } from './actor-walk-to-task'; -import { Task } from '@engine/task'; /** * A task for an actor to interact with another actor. @@ -13,7 +13,10 @@ import { Task } from '@engine/task'; export class ActorActorInteractionTask extends Task { protected arrived: boolean = false; - constructor(protected actor: TActor, protected other: TOther) { + constructor( + protected actor: TActor, + protected other: TOther, + ) { super(); } diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index 0114dc069..26e0d87b2 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -1,5 +1,5 @@ import { Actor } from '@engine/world/actor/actor'; -import { Player } from '@engine/world/actor/player'; +import { Player } from '@engine/world/actor/player/player'; import { ActionTimer } from '@engine/world/actor/timing/action-timer'; /** @@ -28,7 +28,7 @@ export enum QueueType { * Soft tasks cannot be interrupted and always execute when their time comes, * even during delays. Also forces modal interfaces closed. */ - SOFT = 'soft' + SOFT = 'soft', } /** @@ -53,8 +53,6 @@ export interface RequestTickOptions { useGlobalTimer?: boolean; } - - /** * Represents a queued tick task */ @@ -75,7 +73,6 @@ export interface TickTask { useGlobalTimer?: boolean; } - /** * Manages tick-based timing and scheduling for an Actor. * @@ -154,11 +151,7 @@ export class TickQueue { * ``` */ public async requestTicks(options: RequestTickOptions): Promise { - const { - ticks, - type = QueueType.NORMAL, - useGlobalTimer = false - } = options; + const { ticks, type = QueueType.NORMAL, useGlobalTimer = false } = options; // Handle STRONG tasks entering queue if (type === QueueType.STRONG) { @@ -198,7 +191,7 @@ export class TickQueue { reject: rejectFunc!, type, startTick: this.currentTick, - useGlobalTimer + useGlobalTimer, }; this.tasks.push(task); @@ -234,8 +227,7 @@ export class TickQueue { if (task.type === QueueType.SOFT || (!isDelayed && this.canProcessTask(task))) { if (this.shouldCompleteTask(task)) { // Handle modal interfaces for STRONG/SOFT tasks - if (this.actor instanceof Player && - (task.type === QueueType.STRONG || task.type === QueueType.SOFT)) { + if (this.actor instanceof Player && (task.type === QueueType.STRONG || task.type === QueueType.SOFT)) { this.actor.interfaceState.closeAllSlots(); } @@ -276,8 +268,9 @@ export class TickQueue { // For players, handle modal interfaces if (this.actor instanceof Player) { // NORMAL tasks skip if modal interface is open - if (task.type === QueueType.NORMAL - // && this.actor.interfaceState.hasModalOpen() // TODO: implement in player + if ( + task.type === QueueType.NORMAL + // && this.actor.interfaceState.hasModalOpen() // TODO: implement in player ) { return false; } diff --git a/src/engine/world/actor/walking-queue.ts b/src/engine/world/actor/walking-queue.ts index 6341df08e..7b3072dce 100644 --- a/src/engine/world/actor/walking-queue.ts +++ b/src/engine/world/actor/walking-queue.ts @@ -1,6 +1,8 @@ import { regionChangeActionFactory } from '@engine/action/pipe/region-change.action'; -import { activeWorld, Chunk } from '@engine/world'; -import { isNpc, isPlayer } from '@engine/world/actor/util'; +import { activeWorld } from '@engine/world'; +import { Npc } from '@engine/world/actor/npc'; +import { Player } from '@engine/world/actor/player/player'; +import { Chunk } from '@engine/world/map/chunk'; import { Subject } from 'rxjs'; import { Position } from '../position'; import type { Actor } from './actor'; @@ -140,7 +142,7 @@ export class WalkingQueue { } public process(): void { - if(this.actor.busy || this.queue.length === 0 || !this.valid || this.actor.delayManager.isDelayed()) { + if (this.actor.busy || this.queue.length === 0 || !this.valid || this.actor.delayManager.isDelayed()) { this.resetDirections(); return; } @@ -150,7 +152,7 @@ export class WalkingQueue { return; } - if(this.actor.metadata.faceActorClearedByWalking) { + if (this.actor.metadata.faceActorClearedByWalking) { this.actor.clearFaceActor(); } @@ -175,14 +177,14 @@ export class WalkingQueue { let runDir = -1; // Process running if enabled and more steps exist - if(this.actor instanceof Player && this.actor.settings.runEnabled && this.queue.length !== 0) { + if (this.actor instanceof Player && this.actor.settings.runEnabled && this.queue.length !== 0) { const runPosition = this.queue.shift(); if (runPosition && this.actor.pathfinding.canMoveTo(walkPosition, runPosition)) { const runDiffX = runPosition.x - walkPosition.x; const runDiffY = runPosition.y - walkPosition.y; runDir = this.calculateDirection(runDiffX, runDiffY); - if(runDir !== -1) { + if (runDir !== -1) { this.actor.lastMovementPosition = this.actor.position; this.actor.position = runPosition; } @@ -210,13 +212,13 @@ export class WalkingQueue { * @param originalPosition The actor's original position before movement */ private handleChunkUpdate(oldChunk: Chunk, newChunk: Chunk, originalPosition: Position): void { - if(!oldChunk.equals(newChunk)) { - if(this.actor instanceof Player) { + if (!oldChunk.equals(newChunk)) { + if (this.actor instanceof Player) { // Handle map region updates for players - const mapDiffX = this.actor.position.x - (this.actor.lastMapRegionUpdatePosition.chunkX * 8); - const mapDiffY = this.actor.position.y - (this.actor.lastMapRegionUpdatePosition.chunkY * 8); + const mapDiffX = this.actor.position.x - this.actor.lastMapRegionUpdatePosition.chunkX * 8; + const mapDiffY = this.actor.position.y - this.actor.lastMapRegionUpdatePosition.chunkY * 8; - if(mapDiffX < 16 || mapDiffX > 87 || mapDiffY < 16 || mapDiffY > 87) { + if (mapDiffX < 16 || mapDiffX > 87 || mapDiffY < 16 || mapDiffY > 87) { this.actor.updateFlags.mapRegionUpdateRequired = true; this.actor.lastMapRegionUpdatePosition = this.actor.position; } @@ -227,9 +229,11 @@ export class WalkingQueue { this.actor.metadata.updateChunk = { newChunk, oldChunk }; // Call region change action - this.actor.actionPipeline.call('region_change', regionChangeActionFactory( - this.actor, originalPosition, this.actor.position)); - } else if(this.actor instanceof Npc) { + this.actor.actionPipeline.call( + 'region_change', + regionChangeActionFactory(this.actor, originalPosition, this.actor.position), + ); + } else if (this.actor instanceof Npc) { // Handle NPC chunk updates oldChunk.removeNpc(this.actor); newChunk.addNpc(this.actor); diff --git a/src/plugins/skills/crafting/spinning-wheel.plugin.ts b/src/plugins/skills/crafting/spinning-wheel.plugin.ts index 6f4ea3158..b3b48e5c5 100644 --- a/src/plugins/skills/crafting/spinning-wheel.plugin.ts +++ b/src/plugins/skills/crafting/spinning-wheel.plugin.ts @@ -1,16 +1,16 @@ -import { objectInteractionActionHandler } from '@engine/action'; -import { buttonActionHandler } from '@engine/action'; -import { soundIds } from '@engine/world/config/sound-ids'; -import { itemIds } from '@engine/world/config/item-ids'; +import { buttonActionHandler } from '@engine/action/pipe/button.action'; +import { objectInteractionActionHandler } from '@engine/action/pipe/object-interaction.action'; +import { findItem, widgets } from '@engine/config/config-handler'; +import { ContentPlugin } from '@engine/plugins/plugin.types'; +import { Player } from '@engine/world/actor/player/player'; import { Skill } from '@engine/world/actor/skills'; +import { QueueType } from '@engine/world/actor/tick-queue'; import { animationIds } from '@engine/world/config/animation-ids'; +import { itemIds } from '@engine/world/config/item-ids'; import { objectIds } from '@engine/world/config/object-ids'; -import { findItem, widgets } from '@engine/config/config-handler'; +import { soundIds } from '@engine/world/config/sound-ids'; import { logger } from '@runejs/common'; -import { Player } from '@engine/world/actor'; import { take } from 'rxjs/operators'; -import { ContentPlugin } from '@engine/plugins/plugin.types'; -import { QueueType } from '@engine/world/actor/tick-queue'; interface Spinnable { input: number | number[]; @@ -38,12 +38,7 @@ const bowString: Spinnable = { requiredLevel: 10, }; const rootsCbowString: Spinnable = { - input: [ - itemIds.roots.oak, - itemIds.roots.willow, - itemIds.roots.maple, - itemIds.roots.yew, - ], + input: [itemIds.roots.oak, itemIds.roots.willow, itemIds.roots.maple, itemIds.roots.yew], output: itemIds.crossbowString, experience: 15, requiredLevel: 10, @@ -60,10 +55,7 @@ const magicAmuletString: Spinnable = { experience: 30, requiredLevel: 19, }; -const widgetButtonIds: Map = new Map< -number, -SpinnableButton ->([ +const widgetButtonIds: Map = new Map([ [100, { shouldTakeInput: false, count: 1, spinnable: ballOfWool }], [99, { shouldTakeInput: false, count: 5, spinnable: ballOfWool }], [98, { shouldTakeInput: false, count: 10, spinnable: ballOfWool }], @@ -86,8 +78,7 @@ SpinnableButton [111, { shouldTakeInput: true, count: 0, spinnable: sinewCbowString }], ]); - -export const openSpinningInterface: objectInteractionActionHandler = (details) => { +export const openSpinningInterface: objectInteractionActionHandler = details => { details.player.interfaceState.openWidget(widgets.whatWouldYouLikeToSpin, { slot: 'screen', }); @@ -134,7 +125,7 @@ async function spinProduct(player: Player, spinnable: Spinnable, count: number): // Queue as WEAK task await player.tickQueue.requestTicks({ ticks: i === 0 ? 0 : 3, // First action immediate, then 3 tick spacing - type: QueueType.WEAK + type: QueueType.WEAK, }); // Play animation and sound each time @@ -152,9 +143,7 @@ async function spinProduct(player: Player, spinnable: Spinnable, count: number): } } - - -export const buttonClicked: buttonActionHandler = async (details) => { +export const buttonClicked: buttonActionHandler = async details => { // Check if player might be spawning widget clientside if (!details.player.interfaceState.findWidget(459)) { return; @@ -174,7 +163,7 @@ export const buttonClicked: buttonActionHandler = async (details) => { const outputName = findItem(product.spinnable.output)?.name || ''; details.player.sendMessage( `You need a crafting level of ${product.spinnable.requiredLevel} to craft ${outputName.toLowerCase()}.`, - true + true, ); return; } @@ -207,7 +196,7 @@ export const buttonClicked: buttonActionHandler = async (details) => { } }; -export default { +export default ({ pluginId: 'rs:spinning_wheel', hooks: [ { @@ -224,4 +213,4 @@ export default { handler: buttonClicked, }, ], -}; +}); diff --git a/src/plugins/skills/woodcutting/woodcutting.constants.ts b/src/plugins/skills/woodcutting/woodcutting.constants.ts index 34d877d46..8c0900c5e 100644 --- a/src/plugins/skills/woodcutting/woodcutting.constants.ts +++ b/src/plugins/skills/woodcutting/woodcutting.constants.ts @@ -1,9 +1,12 @@ -import { IHarvestable, itemIds, objectIds, soundIds, WeightedItem } from '@engine/world/config'; -import { randomBetween } from '@engine/util'; +import { randomBetween } from '@engine/util/num'; +import { IHarvestable, WeightedItem } from '@engine/world/config/harvestable-object'; +import { itemIds } from '@engine/world/config/item-ids'; +import { objectIds } from '@engine/world/config/object-ids'; +import { soundIds } from '@engine/world/config/sound-ids'; export const WOODCUTTING_SOUNDS = { CHOP: soundIds.axeSwing, // Array of [88, 89, 90] - TREE_DEPLETED: soundIds.oreDepeleted // 3600 + TREE_DEPLETED: soundIds.oreDepeleted, // 3600 }; interface AxeData { @@ -12,8 +15,6 @@ interface AxeData { bonus: number; } - - export const AXES = new Map([ [itemIds.axes.runite, { level: 41, animationId: 867, bonus: 8 }], [itemIds.axes.adamantite, { level: 31, animationId: 869, bonus: 7 }], @@ -21,63 +22,62 @@ export const AXES = new Map([ // [itemIds.axes.black, { level: 11, animationId: 873, bonus: 5 }], [itemIds.axes.steel, { level: 6, animationId: 875, bonus: 4 }], [itemIds.axes.iron, { level: 1, animationId: 877, bonus: 3 }], - [itemIds.axes.bronze, { level: 1, animationId: 879, bonus: 2 }] + [itemIds.axes.bronze, { level: 1, animationId: 879, bonus: 2 }], ]); - - const NORMAL_OBJECTS: Map = new Map([ - ...objectIds.tree.normal.map((tree) => [tree.default, tree.stump]), - ...objectIds.tree.dead.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const ACHEY_OBJECTS: Map = new Map([ - ...objectIds.tree.archey.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const OAK_OBJECTS: Map = new Map([ - ...objectIds.tree.oak.map((tree) => [tree.default, tree.stump]), + ...objectIds.tree.normal.map(tree => [tree.default, tree.stump]), + ...objectIds.tree.dead.map(tree => [tree.default, tree.stump]), ] as [number, number][]); - -const WILLOW_OBJECTS: Map = new Map([ - ...objectIds.tree.willow.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const TEAK_OBJECTS: Map = new Map([ - ...objectIds.tree.teak.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const DRAMEN_OBJECTS: Map = new Map([ - ...objectIds.tree.dramen.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const MAPLE_OBJECTS: Map = new Map([ - ...objectIds.tree.maple.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const HOLLOW_OBJECTS: Map = new Map([ - ...objectIds.tree.hollow.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); +const ACHEY_OBJECTS: Map = new Map([...objectIds.tree.archey.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); + +const OAK_OBJECTS: Map = new Map([...objectIds.tree.oak.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); + +const WILLOW_OBJECTS: Map = new Map([...objectIds.tree.willow.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); + +const TEAK_OBJECTS: Map = new Map([...objectIds.tree.teak.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); + +const DRAMEN_OBJECTS: Map = new Map([...objectIds.tree.dramen.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); + +const MAPLE_OBJECTS: Map = new Map([...objectIds.tree.maple.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); + +const HOLLOW_OBJECTS: Map = new Map([...objectIds.tree.hollow.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); const MAHOGANY_OBJECTS: Map = new Map([ - ...objectIds.tree.mahogany.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - - -const YEW_OBJECTS: Map = new Map([ - ...objectIds.tree.yew.map((tree) => [tree.default, tree.stump]), -] as [number, number][]); - -const MAGIC_OBJECTS: Map = new Map([ - ...objectIds.tree.magic.map((tree) => [tree.default, tree.stump]), + ...objectIds.tree.mahogany.map(tree => [tree.default, tree.stump]), ] as [number, number][]); +const YEW_OBJECTS: Map = new Map([...objectIds.tree.yew.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); - +const MAGIC_OBJECTS: Map = new Map([...objectIds.tree.magic.map(tree => [tree.default, tree.stump])] as [ + number, + number, +][]); const Trees: IHarvestable[] = [ { @@ -88,7 +88,7 @@ const Trees: IHarvestable[] = [ respawnLow: 59, respawnHigh: 98, baseChance: 70, - break: 100 + break: 100, }, { objects: ACHEY_OBJECTS, @@ -98,7 +98,7 @@ const Trees: IHarvestable[] = [ respawnLow: 59, respawnHigh: 98, baseChance: 70, - break: 100 + break: 100, }, { objects: OAK_OBJECTS, @@ -108,7 +108,7 @@ const Trees: IHarvestable[] = [ respawnLow: 14, respawnHigh: 14, baseChance: 50, - break: 100 / 8 + break: 100 / 8, }, { objects: WILLOW_OBJECTS, @@ -118,7 +118,7 @@ const Trees: IHarvestable[] = [ respawnLow: 14, respawnHigh: 14, baseChance: 30, - break: 100 / 8 + break: 100 / 8, }, { objects: TEAK_OBJECTS, @@ -128,7 +128,7 @@ const Trees: IHarvestable[] = [ respawnLow: 15, respawnHigh: 15, baseChance: 0, - break: 100 / 8 + break: 100 / 8, }, { objects: DRAMEN_OBJECTS, @@ -138,7 +138,7 @@ const Trees: IHarvestable[] = [ respawnLow: 0, respawnHigh: 0, baseChance: 100, - break: 0 + break: 0, }, { objects: MAPLE_OBJECTS, @@ -148,7 +148,7 @@ const Trees: IHarvestable[] = [ respawnLow: 59, respawnHigh: 59, baseChance: 0, - break: 100 / 8 + break: 100 / 8, }, { objects: HOLLOW_OBJECTS, @@ -158,7 +158,7 @@ const Trees: IHarvestable[] = [ respawnLow: 43, respawnHigh: 44, baseChance: 0, - break: 100 / 8 + break: 100 / 8, }, { objects: MAHOGANY_OBJECTS, @@ -168,7 +168,7 @@ const Trees: IHarvestable[] = [ respawnLow: 14, respawnHigh: 14, baseChance: -5, - break: 100 / 8 + break: 100 / 8, }, { objects: YEW_OBJECTS, @@ -178,7 +178,7 @@ const Trees: IHarvestable[] = [ respawnLow: 99, respawnHigh: 99, baseChance: -15, - break: 100 / 8 + break: 100 / 8, }, { objects: MAGIC_OBJECTS, @@ -188,7 +188,7 @@ const Trees: IHarvestable[] = [ respawnLow: 199, respawnHigh: 199, baseChance: -25, - break: 100 / 8 + break: 100 / 8, }, { objects: DRAMEN_OBJECTS, @@ -198,7 +198,7 @@ const Trees: IHarvestable[] = [ respawnLow: 0, respawnHigh: 0, baseChance: 100, - break: 0 + break: 0, }, { objects: HOLLOW_OBJECTS, @@ -208,11 +208,10 @@ const Trees: IHarvestable[] = [ respawnLow: 43, respawnHigh: 44, baseChance: 0, - break: 100 / 8 + break: 100 / 8, }, ]; - export function getTreeIds(): number[] { const treeIds: number[] = []; for (const tree of Trees) { @@ -226,8 +225,8 @@ export function getTreeFromHealthy(id: number): IHarvestable { return Trees.find(tree => tree.objects.has(id)) as IHarvestable; } -export function selectWeightedItem(items:string | WeightedItem[]): string { - if(typeof items === 'string') { +export function selectWeightedItem(items: string | WeightedItem[]): string { + if (typeof items === 'string') { return items; } const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); @@ -243,9 +242,8 @@ export function selectWeightedItem(items:string | WeightedItem[]): string { return items[0].itemConfigId; // Fallback to first item } - -export function getPrimaryItem(items:string | WeightedItem[]): string { - if(typeof items === 'string') { +export function getPrimaryItem(items: string | WeightedItem[]): string { + if (typeof items === 'string') { return items; } const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); diff --git a/src/plugins/skills/woodcutting/woodcutting.plugin.ts b/src/plugins/skills/woodcutting/woodcutting.plugin.ts index d3966a36d..83dc8b094 100644 --- a/src/plugins/skills/woodcutting/woodcutting.plugin.ts +++ b/src/plugins/skills/woodcutting/woodcutting.plugin.ts @@ -1,23 +1,24 @@ -import { Player, Skill } from '@engine/world/actor'; -import { LandscapeObject } from '@runejs/filestore'; -import { findItem } from '@engine/config'; +import { ObjectInteractionAction } from '@engine/action/pipe/object-interaction.action'; +import { findItem } from '@engine/config/config-handler'; +import { ContentPlugin } from '@engine/plugins/plugin.types'; +import { randomBetween } from '@engine/util/num'; +import { Player } from '@engine/world/actor/player/player'; +import { Skill } from '@engine/world/actor/skills'; import { QueueType } from '@engine/world/actor/tick-queue'; import { - AXES, getPrimaryItem, + AXES, + WOODCUTTING_SOUNDS, + getPrimaryItem, getTreeFromHealthy, - getTreeIds, selectWeightedItem, - WOODCUTTING_SOUNDS + getTreeIds, + selectWeightedItem, } from '@plugins/skills/woodcutting/woodcutting.constants'; -import { randomBetween } from '@engine/util'; -import { ContentPlugin } from '@engine/plugins/plugin.types'; -import { ObjectInteractionAction } from '@engine/action'; - +import { LandscapeObject } from '@runejs/filestore'; const getBestAxe = (player: Player): number | null => { const availableAxes = [...AXES.entries()] - .filter(([axeId, data]) => - player.hasItemInInventory(axeId) || - player.isItemEquipped(axeId)).filter(([axeId, data]) => player.skills.hasLevel(Skill.WOODCUTTING, data.level)) + .filter(([axeId, data]) => player.hasItemInInventory(axeId) || player.isItemEquipped(axeId)) + .filter(([axeId, data]) => player.skills.hasLevel(Skill.WOODCUTTING, data.level)) .sort(([, a], [, b]) => b.bonus - a.bonus); if (availableAxes.length === 0) { @@ -31,9 +32,7 @@ const getBestAxe = (player: Player): number | null => { const handleSoundCycle = (player: Player, startTick: number): void => { const currentTick = player.tickQueue.currentTick; const relativeTick = (currentTick - startTick) % 3; - const chopSound = WOODCUTTING_SOUNDS.CHOP[ - Math.floor(Math.random() * WOODCUTTING_SOUNDS.CHOP.length) - ]; + const chopSound = WOODCUTTING_SOUNDS.CHOP[Math.floor(Math.random() * WOODCUTTING_SOUNDS.CHOP.length)]; const volumes = [8, 0, 18]; // third, second, first chop player.playSound(chopSound, volumes[relativeTick]); }; @@ -45,7 +44,7 @@ const checkTreeDepletion = (player: Player, tree: LandscapeObject): boolean => { const depletionChance = treeData.break / 100; if (Math.random() < depletionChance) { - const respawnTicks = randomBetween(treeData.respawnLow, treeData.respawnHigh) + const respawnTicks = randomBetween(treeData.respawnLow, treeData.respawnHigh); // Scale by player count in area // const scaledTicks = Math.ceil(respawnTicks * (1 + player.region.playerCount * 0.1)); const scaledTicks = respawnTicks; @@ -54,10 +53,9 @@ const checkTreeDepletion = (player: Player, tree: LandscapeObject): boolean => { const brokenId = treeData.objects.get(tree.objectId); - if(brokenId) { + if (brokenId) { // await tree.transform(tree.nextStage, scaledTicks); - player.instance.replaceGameObject(brokenId, - tree, scaledTicks); + player.instance.replaceGameObject(brokenId, tree, scaledTicks); } return true; @@ -74,7 +72,7 @@ const calculateSuccess = (player: Player, tree: LandscapeObject, axe: number): b // const low = treeData.baseChance + axeData.bonus; const high = treeData.baseChance + axeData.bonus; - return Math.random() * high < (playerLevel + axeData.bonus); + return Math.random() * high < playerLevel + axeData.bonus; }; const startWoodcutting = async (details: ObjectInteractionAction): Promise => { @@ -99,7 +97,9 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise const chopTree = async (): Promise => { // Check if we can still chop if (player.inventory.isFull()) { - player.sendMessage(`Your inventory is too full to hold any more ${findItem(getPrimaryItem(treeData.items))?.name.toLowerCase()}.`); + player.sendMessage( + `Your inventory is too full to hold any more ${findItem(getPrimaryItem(treeData.items))?.name.toLowerCase()}.`, + ); return; } @@ -117,7 +117,7 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise await player.tickQueue.requestTicks({ ticks: 3, type: QueueType.WEAK, - useGlobalTimer: true + useGlobalTimer: true, }); // Check for success @@ -138,7 +138,6 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise // Recursively continue chopping await chopTree(); - } catch (error) { // Handle interruption player.playAnimation(null); @@ -149,21 +148,21 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise await chopTree(); }; -export default { +export default ({ pluginId: 'rs:woodcutting', hooks: [ { type: 'object_interaction', objectIds: getTreeIds(), options: ['chop down'], - handler: async (details) => startWoodcutting(details), - walkTo: true + handler: async details => startWoodcutting(details), + walkTo: true, }, { type: 'item_on_object', objectIds: getTreeIds(), itemIds: [...AXES.keys()], - handler: async (details) => startWoodcutting(details) - } - ] -}; + handler: async details => startWoodcutting(details), + }, + ], +}); From dd7049cf14ce53ce3fa371904589625cce5687e6 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Sat, 1 Feb 2025 22:41:51 +0100 Subject: [PATCH 11/13] compat biome --- src/engine/world/actor/dialogue.ts | 6 ++++-- src/engine/world/actor/tick-queue.ts | 9 +++++---- src/engine/world/actor/walking-queue.ts | 7 ++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/engine/world/actor/dialogue.ts b/src/engine/world/actor/dialogue.ts index 321fda522..1fa31b53e 100644 --- a/src/engine/world/actor/dialogue.ts +++ b/src/engine/world/actor/dialogue.ts @@ -1,7 +1,9 @@ import { findNpc } from '@engine/config/config-handler'; import { wrapText } from '@engine/util/strings'; +import { Actor } from '@engine/world/actor/actor'; import type { Npc } from '@engine/world/actor/npc'; import { Player } from '@engine/world/actor/player/player'; +import { isPlayer } from '@engine/world/actor/util'; import { logger } from '@runejs/common'; import type { ParentWidget, TextWidget } from '@runejs/filestore'; import { filestore } from '@server/game/game-server'; @@ -683,13 +685,13 @@ export async function dialogue( dialogueTree: DialogueTree, additionalOptions?: AdditionalOptions, ): Promise { - const player: Player | undefined = participants.find(p => p instanceof Player); + const player: Player | undefined = participants.find(p => p instanceof Actor && isPlayer(p)); if (!player) { throw new Error('Player instance not provided to dialogue action.'); } - let npcParticipants = participants.filter(p => !(p instanceof Player)) as NpcParticipant[]; + let npcParticipants = participants.filter(p => !(p instanceof Actor && isPlayer(p))) as NpcParticipant[]; if (!npcParticipants) { npcParticipants = []; } diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index 26e0d87b2..9fadee3ce 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -1,6 +1,7 @@ import { Actor } from '@engine/world/actor/actor'; import { Player } from '@engine/world/actor/player/player'; import { ActionTimer } from '@engine/world/actor/timing/action-timer'; +import { isPlayer } from '@engine/world/actor/util'; /** * Represents different queue types for tick tasks. @@ -159,13 +160,13 @@ export class TickQueue { this.clearWeakTasks('Strong task present'); // Force close modal interfaces immediately - if (this.actor instanceof Player) { + if (isPlayer(this.actor)) { this.actor.interfaceState.closeAllSlots(); } } // Handle SOFT tasks entering queue - if (type === QueueType.SOFT && this.actor instanceof Player) { + if (type === QueueType.SOFT && isPlayer(this.actor)) { // Force close modal interfaces immediately this.actor.interfaceState.closeAllSlots(); } @@ -227,7 +228,7 @@ export class TickQueue { if (task.type === QueueType.SOFT || (!isDelayed && this.canProcessTask(task))) { if (this.shouldCompleteTask(task)) { // Handle modal interfaces for STRONG/SOFT tasks - if (this.actor instanceof Player && (task.type === QueueType.STRONG || task.type === QueueType.SOFT)) { + if (isPlayer(this.actor) && (task.type === QueueType.STRONG || task.type === QueueType.SOFT)) { this.actor.interfaceState.closeAllSlots(); } @@ -266,7 +267,7 @@ export class TickQueue { } // For players, handle modal interfaces - if (this.actor instanceof Player) { + if (isPlayer(this.actor)) { // NORMAL tasks skip if modal interface is open if ( task.type === QueueType.NORMAL diff --git a/src/engine/world/actor/walking-queue.ts b/src/engine/world/actor/walking-queue.ts index 7b3072dce..f4b900e65 100644 --- a/src/engine/world/actor/walking-queue.ts +++ b/src/engine/world/actor/walking-queue.ts @@ -2,6 +2,7 @@ import { regionChangeActionFactory } from '@engine/action/pipe/region-change.act import { activeWorld } from '@engine/world'; import { Npc } from '@engine/world/actor/npc'; import { Player } from '@engine/world/actor/player/player'; +import { isNpc, isPlayer } from '@engine/world/actor/util'; import { Chunk } from '@engine/world/map/chunk'; import { Subject } from 'rxjs'; import { Position } from '../position'; @@ -177,7 +178,7 @@ export class WalkingQueue { let runDir = -1; // Process running if enabled and more steps exist - if (this.actor instanceof Player && this.actor.settings.runEnabled && this.queue.length !== 0) { + if (isPlayer(this.actor) && this.actor.settings.runEnabled && this.queue.length !== 0) { const runPosition = this.queue.shift(); if (runPosition && this.actor.pathfinding.canMoveTo(walkPosition, runPosition)) { const runDiffX = runPosition.x - walkPosition.x; @@ -213,7 +214,7 @@ export class WalkingQueue { */ private handleChunkUpdate(oldChunk: Chunk, newChunk: Chunk, originalPosition: Position): void { if (!oldChunk.equals(newChunk)) { - if (this.actor instanceof Player) { + if (isPlayer(this.actor)) { // Handle map region updates for players const mapDiffX = this.actor.position.x - this.actor.lastMapRegionUpdatePosition.chunkX * 8; const mapDiffY = this.actor.position.y - this.actor.lastMapRegionUpdatePosition.chunkY * 8; @@ -233,7 +234,7 @@ export class WalkingQueue { 'region_change', regionChangeActionFactory(this.actor, originalPosition, this.actor.position), ); - } else if (this.actor instanceof Npc) { + } else if (isNpc(this.actor)) { // Handle NPC chunk updates oldChunk.removeNpc(this.actor); newChunk.addNpc(this.actor); From 31dbb2fb59c0dfa525415af649fa000202866b60 Mon Sep 17 00:00:00 2001 From: Henning Berge Date: Sun, 2 Feb 2025 14:08:43 +0100 Subject: [PATCH 12/13] queue fixes, docs and woodcutting no longer allows multiple trees, some timing fixes --- package.json | 11 +- src/engine/world/actor/docs/delays.md | 92 ++++++++++++++ src/engine/world/actor/docs/queue.md | 118 ++++++++++++++++++ src/engine/world/actor/tick-queue.ts | 10 +- .../woodcutting/woodcutting.constants.ts | 40 +++--- .../skills/woodcutting/woodcutting.plugin.ts | 13 +- 6 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 src/engine/world/actor/docs/delays.md create mode 100644 src/engine/world/actor/docs/queue.md diff --git a/package.json b/package.json index 7a5d813bf..0261aa4a8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,13 @@ "type": "git", "url": "git+ssh://git@github.com/runejs/server.git" }, - "keywords": ["runejs", "runescape", "typescript", "game server", "game engine"], + "keywords": [ + "runejs", + "runescape", + "typescript", + "game server", + "game engine" + ], "author": "Tynarus", "license": "GPL-3.0", "bugs": { @@ -73,5 +79,6 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3" - } + }, + "packageManager": "yarn@3.5.0+sha256.e4fc5f94867cd0b492fb0a644f14e7b47c4387bc75d46b56e86db6d0f1a6cb97" } diff --git a/src/engine/world/actor/docs/delays.md b/src/engine/world/actor/docs/delays.md new file mode 100644 index 000000000..1cd5a1112 --- /dev/null +++ b/src/engine/world/actor/docs/delays.md @@ -0,0 +1,92 @@ +Here's the document rewritten in Markdown: + +# Delays + +![Verified naming](label-green) + +Delays are what is commonly referred to as locks or stalls. It is a mechanic deployed by NPCs and players alike to fully block the execution of most scripts and prevent almost all input from players. + +## Table of Contents +- [Known Delays](#known-delays) + - [Arrive Delay](#arrive-delay) + - [Uses](#uses) + - [Normal Delay](#normal-delay) + - [Uses](#uses-1) +- [System Impacts](#system-impacts) +- [Media](#media) +- [References](#references) + +## Known Delays + +There are currently two types of delays known, both of which apply to players and NPCs. When delays are called from within scripts, they also pause the script itself. + +### Arrive Delay + +Arrive delays are used to delay the entity for one server tick if they moved in this or the last server tick. Arrive delays are **not** used to wait until the entity arrives at the destination location.[^2] That is instead done by putting the [normal delay](#normal-delay) in a loop. The commands OldSchool RuneScape uses for arrive delays are `p_arrivedelay` and `npc_arrivedelay` for players and NPCs respectively.[^1] + +#### Uses + +Arrive delays are often used to properly synchronize animations up with movement. An example of this can be seen when mining a rock. If you stand one game square away from the rock, then click to mine it, your character will get delayed for one server tick on the tick that you arrive by the rock. Any input you provide during that one server tick is completely ignored, as your character will be under the effects of a delay. Although rather rare, arrive delays can also be used when interacting with NPCs, as one such example can be seen when using a [poisoned tofu](https://oldschool.runescape.wiki/w/Poisoned_tofu) on a [penance healer](https://oldschool.runescape.wiki/w/Penance_Healer). + +### Normal Delay + +Normal delays are used to pause scripts from executing for a specified period of time, while also preventing any interruptions from other sources, such as a player's own input. Normal delays require the duration in server ticks to be passed as an argument to the command itself. It is not possible to conditionally use delays, although it is possible to chain multiple normal delay calls together, with the code you wish to execute in between those delays.[^1] The commands OldSchool RuneScape uses for normal delays are `p_delay` and `npc_delay` for players and NPCs respectively. + +#### Uses + +Normal delays are widely used around the game. Some uses are mentioned below: + +* Teleportation +* Cutscenes +* Death +* Agility shortcuts and obstacles + +## System Impacts + +This section of the document explains how delays impact other core systems of OldSchool RuneScape. Below are the core systems, and descriptions on how delays impact them: + +* **Timers:** + * While timers continue to tick down, they will pause once the timer reaches 0, until the delay ends. It is unable to execute the script behind the timer itself while a delay exists. + +* **Entity interactions:** + * While a delay is active, entity interactions do not get processed. + +* **Interface interactions:** + * Interface clicks do go through, although it is up to the individual script behind a button to determine whether the effects of clicking it will go through or be ignored while under the effects of a delay. + * An example of a button click going through while delayed is changing music in the music player. It will allow the player to change music just fine. + * An example of a button click **not** going through while delayed is unequipping gear. The click is simply ignored altogether. + +* **Queues:** + * While a delay is active, queues do not get processed. + +* **Route events:** + * While route events do not get processed under delays, existing pre-determined(prior to the delay) movement will still continue to process. + +## Media + +*Below is a gif of the rock mining arrive delay taking effect. During that one specific tick upon arriving by a rock, your character is under the effects of a delay, meaning normal interruptions such as walking away do not get processed.* + +[Mining arrive delay example] + +*Below is a gif of a normal [Falador teleport](https://oldschool.runescape.wiki/w/Falador_Teleport), which happens to cause a three tick delay.* + +[Teleport delay example] + +*Mod Ash providing insight to their delays system.* + +[Mod Ash tweets] + +*Mod Ash explaining arrive delays only lasting one server tick, and mentioning the existence of npc_arrivedelay.* + +[Arrive delay clarification] + +## References + +[^1]: [Mod Ash' tweets on delays](https://twitter.com/ZenKris21/status/1431228469124403200) +[^2]: [Mod Ash' tweets on arrive delays specifically](https://twitter.com/ZenKris21/status/1431945368929984512) + +--- + +*Copyright © 2021-2022 Kris. Distributed by an [MIT license](https://github.com/Z-Kris/osrs-docs/blob/master/LICENSE).* + +[Edit this page on GitHub](https://github.com/Z-Kris/osrs-docs/tree/master/docs/mechanics/delays.md) diff --git a/src/engine/world/actor/docs/queue.md b/src/engine/world/actor/docs/queue.md new file mode 100644 index 000000000..f2d8ee708 --- /dev/null +++ b/src/engine/world/actor/docs/queue.md @@ -0,0 +1,118 @@ +# Queues + +![Verified naming] + +The queue system is used by both players and NPCs to queue the execution of a script in a central place with a predefined order of execution. Queues are often used for skilling that doesn't involve interacting with entities, or getting hit by something. + +## Table of Contents +- [Player Queue](#player-queue) + - [Queue Types](#queue-types) + - [Processing](#processing) + - [Long Queue](#long-queue) + - [Known Uses](#known-uses) + - [Example Scenarios](#example-scenarios) +- [NPC Queue](#npc-queue) +- [Area Queue](#area-queue) + +## Player Queue + +Contrary to popular belief, there is a single queue for players, excluding the [area queue](#area-queue) which will be covered below. All scripts, regardless of the queue type used, will go to the end of the same queue. There is no known cap for the number of scripts that may reside in the queue. + +### Queue Types + +There are four known queue types used for the player queue. + +#### Weak +* Removed from the queue if there are any strong scripts in the queue prior to the queue being processed. +* Removed from the queue upon any interruptions, some of which are: + * Interacting with an entity or clicking on a game square + * Interacting with an item in your inventory + * Unequipping an item + * Opening an interface + * Closing an interface + * Dragging items in inventory +* In general, it seems like any action which closes an interface also clears all weak scripts from the queue. + +#### Normal +* Skipped in the execution block if the player has a modal interface open at the time. + +#### Strong +* Removes all weak scripts from the queue prior to being processed. +* Closes modal interface prior to executing. + +#### Soft +* Cannot be paused or interrupted. It will always execute as long as the timer behind it is up. +* Closes modal interface prior to executing. + +### Processing + +*The processing block for player queue has undergone two large changes in 2021. The below explanation strictly only applies to the current version of the queue.* + +The queue does not get processed if the player is under a delay. At the start of the processing block, the queue is iterated and checked for any strong scripts. If a strong script is in the queue, modal interface is closed before the processing begins. In addition to this, if a strong script exists in the queue, all weak scripts will be removed from the queue prior to the processing start. + +The queue is processed in the exact order in which the scripts were added to it. The processing happens in an indefinite loop. The loop only exits if this condition becomes true: + +* If all the scripts were skipped in the last loop. Meaning none of the scripts from the very first entry to the very last one were processed. + +While going over the scripts, ones which are set to execute in the future are skipped. If there's a normal script being processed, it gets skipped if the player has a modal interface open. If a strong or soft script is processed, modal interface is forcibly closed prior to it processing. If any script sets a delay, processing further scripts cannot happen, and all scripts **except** for soft thereafter will be skipped. As mentioned above, soft scripts cannot be interrupted in any way, and will process even if the player is delayed. The script will be resumed when the delay ends at the start of the tick, although it will not continue processing any other scripts in the queue. + +It should be noted that if a script queues another script, the earliest that the queued script may execute is the following server tick. However, even though it cannot execute, it will still be checked for in the processing loop. This can be observed through the strong scripts, which, if queued from within another script, will still be processed and as such will close the modal interface. + +[Code examples and diagrams omitted for brevity] + +### Long Queue + +The long queue is a normal queue that comes with extra behaviour for how the script should be processed if the player attempts to log out before the script has been processed. The `longqueue` command consists of three primary arguments, along with any script-specific arguments: + +* Script label +* Any arguments specific to the script itself (variable-size, can be blank) +* Delay until execution +* Behavioral type + * There are two types: + * Accelerate + * Implies that on logout, the intended delay until the script is meant to execute is ignored, and the script will attempt to process each tick, as long as the rest of the conditions allow for it. + * Discard + * Implies that on logout, the script will just be discarded. + +#### Use Case + +There is only one confirmed use case of a long queue: `longqueue(my2arm_throneroom_resetcam,0,0,^discard);` The string in that command is the label of the script, followed by an argument for the `my2arm_throneroom_resetcam` script, followed by the delay until the script will be executed, and ending with the behavioral type of `^discard`, meaning the script will just be discarded if the player logs out. + +### Known Uses + +Below is a small list of known uses of queues, along with their types: + +* Damage + * Strong type + * Delayed damage is included by this, so for example sending out a spell + * Not all damage necessarily goes through the strong queue, some exceptions to this are: + * Divine potions apply damage right in the item script, do not use any queues. + * Some damage, although rather rare, will use the normal type instead. An example of this is recoil damage. + +* Scrawled note + * Normal type + * Reading the notes opens the initial interface immediately in the script that handles the item click, but also queues a normal script to open the second interface, which is the dialogue behind it. + +* Fletching + * Weak type + +* Changing window mode (e.g. going to resizable mode) + * Soft type + +[Example scenarios section omitted for brevity] + +## NPC Queue + +NPCs, like players, only have one queue. A difference between players and NPCs however is that while player queues can have four strengths, NPCs all only have one. + +*This section is incomplete and will be expanded upon later.* + +## Area Queue + +Area queue is used strictly by players to execute various scripts, such as entering the multiway zones, unlocking music, or updating the state of farming patches. + +*This section is incomplete and will be expanded upon later.* + +--- + +*Copyright © 2021-2022 Kris. Distributed by an MIT license.* diff --git a/src/engine/world/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts index 9fadee3ce..7c4fa9fce 100644 --- a/src/engine/world/actor/tick-queue.ts +++ b/src/engine/world/actor/tick-queue.ts @@ -157,9 +157,15 @@ export class TickQueue { // Handle STRONG tasks entering queue if (type === QueueType.STRONG) { // Clear weak tasks first - this.clearWeakTasks('Strong task present'); + this.tasks = this.tasks.filter(task => { + if (task.type === QueueType.WEAK) { + task.reject('Strong task present'); + return false; + } + return true; + }); - // Force close modal interfaces immediately + // Force close modal interfaces if (isPlayer(this.actor)) { this.actor.interfaceState.closeAllSlots(); } diff --git a/src/plugins/skills/woodcutting/woodcutting.constants.ts b/src/plugins/skills/woodcutting/woodcutting.constants.ts index 8c0900c5e..f144c8c54 100644 --- a/src/plugins/skills/woodcutting/woodcutting.constants.ts +++ b/src/plugins/skills/woodcutting/woodcutting.constants.ts @@ -85,8 +85,8 @@ const Trees: IHarvestable[] = [ items: 'rs:logs', level: 1, experience: 25, - respawnLow: 59, - respawnHigh: 98, + respawnLow: 27, + respawnHigh: 45, baseChance: 70, break: 100, }, @@ -95,8 +95,8 @@ const Trees: IHarvestable[] = [ items: 'rs:achey_logs', level: 1, experience: 25, - respawnLow: 59, - respawnHigh: 98, + respawnLow: 27, + respawnHigh: 45, baseChance: 70, break: 100, }, @@ -105,8 +105,8 @@ const Trees: IHarvestable[] = [ items: 'rs:oak_logs', level: 15, experience: 37.5, - respawnLow: 14, - respawnHigh: 14, + respawnLow: 50, + respawnHigh: 100, baseChance: 50, break: 100 / 8, }, @@ -115,8 +115,8 @@ const Trees: IHarvestable[] = [ items: 'rs:willow_logs', level: 30, experience: 67.5, - respawnLow: 14, - respawnHigh: 14, + respawnLow: 50, + respawnHigh: 100, baseChance: 30, break: 100 / 8, }, @@ -125,8 +125,8 @@ const Trees: IHarvestable[] = [ items: 'rs:teak_logs', level: 35, experience: 85, - respawnLow: 15, - respawnHigh: 15, + respawnLow: 100, + respawnHigh: 150, baseChance: 0, break: 100 / 8, }, @@ -145,8 +145,8 @@ const Trees: IHarvestable[] = [ items: 'rs:maple_logs', level: 45, experience: 100, - respawnLow: 59, - respawnHigh: 59, + respawnLow: 100, + respawnHigh: 200, baseChance: 0, break: 100 / 8, }, @@ -155,8 +155,8 @@ const Trees: IHarvestable[] = [ items: 'rs:bark', // You'll need to add this to logs.json level: 45, experience: 82.5, - respawnLow: 43, - respawnHigh: 44, + respawnLow: 27, + respawnHigh: 45, baseChance: 0, break: 100 / 8, }, @@ -165,8 +165,8 @@ const Trees: IHarvestable[] = [ items: 'rs:mahogany_logs', level: 50, experience: 125, - respawnLow: 14, - respawnHigh: 14, + respawnLow: 150, + respawnHigh: 250, baseChance: -5, break: 100 / 8, }, @@ -175,8 +175,8 @@ const Trees: IHarvestable[] = [ items: 'rs:yew_logs', level: 60, experience: 175, - respawnLow: 99, - respawnHigh: 99, + respawnLow: 167, + respawnHigh: 300, baseChance: -15, break: 100 / 8, }, @@ -185,8 +185,8 @@ const Trees: IHarvestable[] = [ items: 'rs:magic_logs', level: 75, experience: 250, - respawnLow: 199, - respawnHigh: 199, + respawnLow: 200, + respawnHigh: 500, baseChance: -25, break: 100 / 8, }, diff --git a/src/plugins/skills/woodcutting/woodcutting.plugin.ts b/src/plugins/skills/woodcutting/woodcutting.plugin.ts index 83dc8b094..759782dd7 100644 --- a/src/plugins/skills/woodcutting/woodcutting.plugin.ts +++ b/src/plugins/skills/woodcutting/woodcutting.plugin.ts @@ -89,11 +89,20 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise const axe = getBestAxe(player); if (!axe) return; + // Request a STRONG type tick to clear any existing woodcutting tasks + await player.tickQueue.requestTicks({ + ticks: 0, + type: QueueType.STRONG, + }); // Initial setup const startTick = player.tickQueue.currentTick; player.sendMessage('You swing your axe at the tree.'); + const axeData = AXES.get(axe); + if (axeData) { + player.playAnimation(axeData.animationId); + } const chopTree = async (): Promise => { // Check if we can still chop if (player.inventory.isFull()) { @@ -113,10 +122,10 @@ const startWoodcutting = async (details: ObjectInteractionAction): Promise handleSoundCycle(player, startTick); try { - // Wait for woodcutting timer (3 ticks normally, can be manipulated) + // Wait for woodcutting timer with proper queue type await player.tickQueue.requestTicks({ ticks: 3, - type: QueueType.WEAK, + type: QueueType.WEAK, // Explicitly specify WEAK type for skilling useGlobalTimer: true, }); From 8dcebc6c9cb7bdcb8d600c5d451fc2af31ab69f5 Mon Sep 17 00:00:00 2001 From: Borig Date: Fri, 7 Feb 2025 13:58:51 +1300 Subject: [PATCH 13/13] Test area now with shop and bank --- data/config/item-spawns/test-area.json | 10 +++++ data/config/items/logs.json | 4 -- .../npc-spawns/test-area/test-shops.json | 9 ++++ data/config/npcs/general.json | 3 ++ data/config/scenery-spawns.yaml | 44 +++++++++++++++++++ data/config/shops/test-area/test-shop.json | 40 +++++++++++++++++ src/engine/world/actor/skills.ts | 28 ++++++------ src/engine/world/config/object-ids.ts | 1 + src/engine/world/items/item-container.ts | 2 +- .../items/shopping/sell-to-shop.plugin.ts | 16 ++++--- .../npcs/test-area/test-shop.plugin.ts | 9 ++++ src/plugins/objects/bank/bank.plugin.ts | 7 +++ 12 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 data/config/item-spawns/test-area.json create mode 100644 data/config/npc-spawns/test-area/test-shops.json create mode 100644 data/config/shops/test-area/test-shop.json create mode 100644 src/plugins/npcs/test-area/test-shop.plugin.ts diff --git a/data/config/item-spawns/test-area.json b/data/config/item-spawns/test-area.json new file mode 100644 index 000000000..a1bb276ae --- /dev/null +++ b/data/config/item-spawns/test-area.json @@ -0,0 +1,10 @@ +[ + { + "item": "rs:bronze_axe", + "amount": 1, + "spawn_x": 2713, + "spawn_y": 9807, + "instance": "global", + "respawn": 25 + } +] diff --git a/data/config/items/logs.json b/data/config/items/logs.json index 6a3a63733..80c214c41 100644 --- a/data/config/items/logs.json +++ b/data/config/items/logs.json @@ -70,10 +70,6 @@ "extends": "rs:log", "game_id": 1513 }, - "rs:magic_pyre_logs": { - "extends": "rs:log", - "game_id": 1513 - }, "rs:bark": { "game_id": 3239, "examine": "Bark from a hollow tree.", diff --git a/data/config/npc-spawns/test-area/test-shops.json b/data/config/npc-spawns/test-area/test-shops.json new file mode 100644 index 000000000..073969e86 --- /dev/null +++ b/data/config/npc-spawns/test-area/test-shops.json @@ -0,0 +1,9 @@ +[ + { + "npc": "rs:dromunds_cat", + "spawn_x": 2712, + "spawn_y": 9806, + "movement_radius": 0, + "face": "SOUTH" + } +] diff --git a/data/config/npcs/general.json b/data/config/npcs/general.json index 7166e226a..47c9bdbd7 100644 --- a/data/config/npcs/general.json +++ b/data/config/npcs/general.json @@ -17,5 +17,8 @@ "skills": { "hitpoints": 1 } + }, + "rs:dromunds_cat": { + "game_id": 2140 } } diff --git a/data/config/scenery-spawns.yaml b/data/config/scenery-spawns.yaml index 65aaee820..0d11bb442 100644 --- a/data/config/scenery-spawns.yaml +++ b/data/config/scenery-spawns.yaml @@ -72,3 +72,47 @@ type: 10 orientation: 1 # End lumby castle roof bank construction objects + # Test area objects +- objectId: 1276 + x: 2711 + y: 9807 + level: 0 + type: 10 + orientation: 1 +- objectId: 1281 + x: 2710 + y: 9809 + level: 0 + type: 10 + orientation: 1 +- objectId: 1308 + x: 2711 + y: 9812 + level: 0 + type: 10 + orientation: 1 +- objectId: 1307 + x: 2711 + y: 9814 + level: 0 + type: 10 + orientation: 1 +- objectId: 1309 + x: 2710 + y: 9816 + level: 0 + type: 10 + orientation: 1 +- objectId: 1306 + x: 2711 + y: 9819 + level: 0 + type: 10 + orientation: 1 +- objectId: 4483 + x: 2713 + y: 9805 + level: 0 + type: 10 + orientation: 4 + # End test area diff --git a/data/config/shops/test-area/test-shop.json b/data/config/shops/test-area/test-shop.json new file mode 100644 index 000000000..2d41b7136 --- /dev/null +++ b/data/config/shops/test-area/test-shop.json @@ -0,0 +1,40 @@ +{ + "rs:test_shop": { + "name": "Testing Shop", + "shop_sell_rate": 1.0, + "shop_buy_rate": 1.0, + "rate_modifier": 0.03, + "stock": [ + { + "itemKey": "rs:logs", + "amount": 100, + "restock": 25000 + }, + { + "itemKey": "rs:oak_logs", + "amount": 100, + "restock": 25000 + }, + { + "itemKey": "rs:willow_logs", + "amount": 100, + "restock": 25000 + }, + { + "itemKey": "rs:maple_logs", + "amount": 100, + "restock": 25000 + }, + { + "itemKey": "rs:yew_logs", + "amount": 100, + "restock": 25000 + }, + { + "itemKey": "rs:magic_logs", + "amount": 100, + "restock": 25000 + } + ] + } +} diff --git a/src/engine/world/actor/skills.ts b/src/engine/world/actor/skills.ts index 24fce3fc8..42661f4d1 100644 --- a/src/engine/world/actor/skills.ts +++ b/src/engine/world/actor/skills.ts @@ -303,20 +303,20 @@ export class Skills extends SkillShortcuts { const skillName = achievementDetails.name.toLowerCase(); - player.modifyWidget(widgetId, { - childId: 0, - text: - `Congratulations, you just advanced ${startsWithVowel(skillName) ? 'an' : 'a'} ` + `${skillName} level.`, - }); - player.modifyWidget(widgetId, { - childId: 1, - text: `Your ${skillName} level is now ${level}.`, - }); - - player.interfaceState.openWidget(widgetId, { - slot: 'chatbox', - multi: true, - }); + // player.modifyWidget(widgetId, { + // childId: 0, + // text: + // `Congratulations, you just advanced ${startsWithVowel(skillName) ? 'an' : 'a'} ` + `${skillName} level.`, + // }); + // player.modifyWidget(widgetId, { + // childId: 1, + // text: `Your ${skillName} level is now ${level}.`, + // }); + + // player.interfaceState.openWidget(widgetId, { + // slot: 'chatbox', + // multi: true, + // }); player.playGraphics({ id: gfxIds.levelUpFireworks, delay: 0, height: 125 }); // @TODO sounds diff --git a/src/engine/world/config/object-ids.ts b/src/engine/world/config/object-ids.ts index a6ce73866..271df4bc5 100644 --- a/src/engine/world/config/object-ids.ts +++ b/src/engine/world/config/object-ids.ts @@ -4,6 +4,7 @@ export const objectIds = { fire: 2732, spinningWheel: 2644, bankBooth: 2213, + bankChest: 4483, depositBox: 9398, shortCuts: { stile: 12982, diff --git a/src/engine/world/items/item-container.ts b/src/engine/world/items/item-container.ts index fcaf3c08d..7581eb90b 100644 --- a/src/engine/world/items/item-container.ts +++ b/src/engine/world/items/item-container.ts @@ -134,7 +134,7 @@ export class ItemContainer { for (let i = 0; i < this._size; i++) { const inventoryItem = this._items[i]; - if (inventoryItem === null) { + if (inventoryItem == null) { continue; } diff --git a/src/plugins/items/shopping/sell-to-shop.plugin.ts b/src/plugins/items/shopping/sell-to-shop.plugin.ts index f2dccf00d..ecaff4edb 100644 --- a/src/plugins/items/shopping/sell-to-shop.plugin.ts +++ b/src/plugins/items/shopping/sell-to-shop.plugin.ts @@ -53,19 +53,23 @@ export const handler: itemInteractionActionHandler = details => { inventory.set(itemSlot, { itemId, amount: inventoryItem.amount - sellAmount }); } } else { - const foundItems = inventory.items.map((item, i) => (item !== null && item.itemId === itemId ? i : null)).filter(i => i !== null); - if (foundItems.length < sellAmount) { - sellAmount = foundItems.length; + const inventorySlots: number[] = inventory.items + // Get all the inventory slots that contain the item we are selling. + .map((item, i) => (item !== null && item.itemId === itemId ? i : null)) + .filter(i => i !== null); + + if (inventorySlots.length < sellAmount) { + sellAmount = inventorySlots.length; } for (let i = 0; i < sellAmount; i++) { - const item = foundItems[i]; + const itemSlot = inventorySlots[i]; - if (!item) { + if (itemSlot == null) { throw new Error(`Inventory item was not present, for item id ${itemId} in inventory, while trying to sell`); } - inventory.remove(item); + inventory.remove(itemSlot); } } diff --git a/src/plugins/npcs/test-area/test-shop.plugin.ts b/src/plugins/npcs/test-area/test-shop.plugin.ts new file mode 100644 index 000000000..6c25e7cd8 --- /dev/null +++ b/src/plugins/npcs/test-area/test-shop.plugin.ts @@ -0,0 +1,9 @@ +import type { npcInteractionActionHandler } from '@engine/action/pipe/npc-interaction.action'; +import { findShop } from '@engine/config/config-handler'; + +const tradeAction: npcInteractionActionHandler = ({ player }) => findShop('rs:test_shop')?.open(player); + +export default { + pluginId: 'rs:test-shop', + hooks: [{ type: 'npc_interaction', npcs: 'rs:dromunds_cat', options: 'talk-to', walkTo: true, handler: tradeAction }], +}; diff --git a/src/plugins/objects/bank/bank.plugin.ts b/src/plugins/objects/bank/bank.plugin.ts index a55cbcad7..3aeedca35 100644 --- a/src/plugins/objects/bank/bank.plugin.ts +++ b/src/plugins/objects/bank/bank.plugin.ts @@ -311,6 +311,13 @@ export default { walkTo: true, handler: useBankBoothAction, }, + { + type: 'object_interaction', + objectIds: objectIds.bankChest, + options: ['use'], + walkTo: true, + handler: openBankInterface, + }, { type: 'object_interaction', objectIds: objectIds.bankBooth,