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/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/action/pipe/task/walk-to-actor-plugin-task.ts b/src/engine/action/pipe/task/walk-to-actor-plugin-task.ts index f58f25842..941fbf555 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 @@ -61,9 +61,9 @@ export class WalkToActorPluginTask< /** * Executed every tick to check if the player has arrived yet and calls the plugins if so. */ - public execute(): void { + public async execute(): Promise { // 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/action/pipe/task/walk-to-object-plugin-task.ts b/src/engine/action/pipe/task/walk-to-object-plugin-task.ts index 0325b90d4..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,6 +3,7 @@ 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'; /** @@ -35,38 +36,49 @@ 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; } - /** - * 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 + // 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 || !plugin.handler) { - return; - } + if (!plugin?.handler) return; const action = { player: this.actor, diff --git a/src/engine/net/inbound-packets/walk.packet.ts b/src/engine/net/inbound-packets/walk.packet.ts index 35a17ff04..af459fee0 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(); diff --git a/src/engine/plugins/plugin.types.ts b/src/engine/plugins/plugin.types.ts new file mode 100644 index 000000000..fc1280ac5 --- /dev/null +++ b/src/engine/plugins/plugin.types.ts @@ -0,0 +1,219 @@ +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 +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; + handler: (details: ObjectInteractionAction) => any; +} + +// 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 d64b9705e..5b0c2ab7c 100644 --- a/src/engine/task/impl/actor-actor-interaction-task.ts +++ b/src/engine/task/impl/actor-actor-interaction-task.ts @@ -1,5 +1,5 @@ -import type { Actor } from '@engine/world/actor/actor'; -import type { Position } from '@engine/world/position'; +import { Task } from '@engine/task/task'; +import { Actor } from '@engine/world/actor/actor'; import { ActorWalkToTask } from './actor-walk-to-task'; /** @@ -10,76 +10,34 @@ import { ActorWalkToTask } from './actor-walk-to-task'; * * @author jameskmonger */ -export abstract class ActorActorInteractionTask extends ActorWalkToTask< - TActor, - () => Position -> { - private _other: TOtherActor; - - /** - * @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; +export class ActorActorInteractionTask extends Task { + protected arrived: boolean = false; + + 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 bd2bf4ae7..22bef1571 100644 --- a/src/engine/task/impl/actor-landscape-object-interaction-task.ts +++ b/src/engine/task/impl/actor-landscape-object-interaction-task.ts @@ -15,7 +15,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. @@ -45,6 +45,11 @@ export abstract class ActorLandscapeObjectInteractionTask { + if(this.position.level !== target.position.level) { return false; } @@ -343,6 +347,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; } @@ -350,6 +355,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); } @@ -359,6 +365,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; @@ -508,12 +517,33 @@ export abstract class Actor { this.active = false; this.scheduler.clear(); + this.tickQueue.destroy(); } protected 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(); } + /** + * Request a tick delay for an action + * @param ticks Number of ticks to wait + * @param options Additional options for the tick request + */ + public async requestTickDelay(ticks: number, options: Omit = {}): Promise { + return this.tickQueue.requestTicks({ + ...options, + ticks, + }); + } + public get position(): Position { return this._position; } 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/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/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/npc.ts b/src/engine/world/actor/npc.ts index 92b89e137..25ee93954 100644 --- a/src/engine/world/actor/npc.ts +++ b/src/engine/world/actor/npc.ts @@ -213,6 +213,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 b7ee8ef2f..fe831ab1f 100644 --- a/src/engine/world/actor/player/player.ts +++ b/src/engine/world/actor/player/player.ts @@ -750,13 +750,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/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/actor/tick-queue.ts b/src/engine/world/actor/tick-queue.ts new file mode 100644 index 000000000..7c4fa9fce --- /dev/null +++ b/src/engine/world/actor/tick-queue.ts @@ -0,0 +1,327 @@ +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. + * + * @see {@link https://osrs-docs.com/docs/mechanics/queues/#queue-types} + */ +export enum QueueType { + /** + * Weak tasks are removed by any interruption (movement, combat, interfaces, etc) + * and are cleared if any Strong tasks exist in the queue + */ + WEAK = 'weak', + + /** + * Normal tasks are processed normally but skipped if a modal interface is open + */ + NORMAL = 'normal', + + /** + * Strong tasks remove all Weak tasks from the queue and force close modal interfaces + */ + STRONG = 'strong', + + /** + * Soft tasks cannot be interrupted and always execute when their time comes, + * even during delays. Also forces modal interfaces closed. + */ + SOFT = 'soft', +} + +/** + * Configuration options for requesting a tick task + */ +export interface RequestTickOptions { + /** + * Number of game ticks to wait before executing + */ + ticks: number; + + /** + * The type of task - determines how it behaves regarding interruptions + * @default QueueType.NORMAL + */ + type?: QueueType; + + /** + * Whether to use the global action timer that can be manipulated + * @default false + */ + useGlobalTimer?: boolean; +} + +/** + * 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; + /** Type of task */ + type: QueueType; + /** Tick number when task was started */ + startTick: number; + + useGlobalTimer?: boolean; +} + +/** + * Manages tick-based timing and scheduling for an Actor. + * + * 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 { + /** Current game tick counter */ + 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 + */ + constructor(private actor: Actor) { + // Subscribe to movement events to clear weak tasks + this.actor.walkingQueue.movementEvent.subscribe(() => { + this.clearWeakTasks('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 { + * await actor.tickQueue.requestTicks({ + * ticks: 3, + * type: QueueType.WEAK + * }); + * // Action after 3 ticks if not interrupted + * } catch (error) { + * // Handle interruption + * } + * ``` + */ + public async requestTicks(options: RequestTickOptions): Promise { + const { ticks, type = QueueType.NORMAL, useGlobalTimer = false } = options; + + // Handle STRONG tasks entering queue + if (type === QueueType.STRONG) { + // Clear weak tasks first + this.tasks = this.tasks.filter(task => { + if (task.type === QueueType.WEAK) { + task.reject('Strong task present'); + return false; + } + return true; + }); + + // Force close modal interfaces + if (isPlayer(this.actor)) { + this.actor.interfaceState.closeAllSlots(); + } + } + + // Handle SOFT tasks entering queue + if (type === QueueType.SOFT && isPlayer(this.actor)) { + // Force close modal interfaces immediately + this.actor.interfaceState.closeAllSlots(); + } + + if (useGlobalTimer) { + this.actionTimer.setTimer(ticks); + } + + let resolveFunc: () => void; + let rejectFunc: (reason?: any) => void; + + const promise = new Promise((resolve, reject) => { + resolveFunc = resolve; + rejectFunc = reject; + }); + + 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, + useGlobalTimer, + }; + + this.tasks.push(task); + return promise; + } + + /** + * 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++; + + this.actionTimer.tick(); + // 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.shouldCompleteTask(task)) { + // Handle modal interfaces for STRONG/SOFT tasks + if (isPlayer(this.actor) && (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); + } + + 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 + * @returns True if task can be processed, false otherwise + */ + + private canProcessTask(task: TickTask): boolean { + // Check if task should execute in future + if (this.currentTick < task.startTick + task.ticks) { + return false; + } + + // For players, handle modal interfaces + if (isPlayer(this.actor)) { + // NORMAL tasks skip if modal interface is open + if ( + task.type === QueueType.NORMAL + // && this.actor.interfaceState.hasModalOpen() // TODO: implement in player + ) { + return false; + } + + // STRONG/SOFT tasks force close interfaces before processing + if (task.type === QueueType.STRONG || task.type === QueueType.SOFT) { + this.actor.interfaceState.closeAllSlots(); + } + } + + // Weak tasks interrupted by strong tasks + if (task.type === QueueType.WEAK && this.hasStrongTask()) { + task.reject('Strong task present'); + return false; + } + + return true; + } + + /** + * Checks if queue contains any strong tasks + */ + private hasStrongTask(): boolean { + return this.tasks.some(task => task.type === QueueType.STRONG); + } + + /** + * Cleans up the tick queue when the actor is destroyed. + * Rejects all pending tasks. + */ + public destroy(): void { + this.rejectAllTasks('Actor destroyed'); + } + + /** + * Rejects all tasks in the queue + * @param reason The reason for rejection + */ + 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/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/actor/walking-queue.ts b/src/engine/world/actor/walking-queue.ts index 787d90413..f4b900e65 100644 --- a/src/engine/world/actor/walking-queue.ts +++ b/src/engine/world/actor/walking-queue.ts @@ -1,6 +1,9 @@ import { regionChangeActionFactory } from '@engine/action/pipe/region-change.action'; 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'; import type { Actor } from './actor'; @@ -140,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(); } @@ -175,69 +177,68 @@ export class WalkingQueue { let runDir = -1; - // @TODO npc running - if (isPlayer(this.actor)) { - 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 (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; + 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 (isPlayer(this.actor)) { - 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 (isPlayer(this.actor)) { - this.actor.metadata.updateChunk = { newChunk, oldChunk }; - - this.actor.actionPipeline.call( - 'region_change', - regionChangeActionFactory(this.actor, originalPosition, this.actor.position), - ); - } else if (isNpc(this.actor)) { - 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 (isNpc(this.actor)) { + // Handle NPC chunk updates + oldChunk.removeNpc(this.actor); + newChunk.addNpc(this.actor); } - } else { - this.resetDirections(); - this.clear(); } } diff --git a/src/engine/world/config/harvestable-object.ts b/src/engine/world/config/harvestable-object.ts index ed5faa633..aedc1c801 100644 --- a/src/engine/world/config/harvestable-object.ts +++ b/src/engine/world/config/harvestable-object.ts @@ -1,7 +1,7 @@ import { randomBetween } from '@engine/util/num'; import { objectIds } from '@engine/world/config/object-ids'; -interface WeightedItem { +export interface WeightedItem { itemConfigId: string; weight: number; } @@ -281,139 +281,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]; } @@ -422,10 +289,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) { @@ -447,13 +310,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/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 cbb2edea8..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; } @@ -277,6 +277,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 9a22576d6..5d6eb6178 100644 --- a/src/engine/world/position.ts +++ b/src/engine/world/position.ts @@ -50,7 +50,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); @@ -70,18 +72,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/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, diff --git a/src/plugins/skills/crafting/spinning-wheel.plugin.ts b/src/plugins/skills/crafting/spinning-wheel.plugin.ts index 5b43f5388..b3b48e5c5 100644 --- a/src/plugins/skills/crafting/spinning-wheel.plugin.ts +++ b/src/plugins/skills/crafting/spinning-wheel.plugin.ts @@ -1,14 +1,16 @@ -import type { ButtonAction, buttonActionHandler } from '@engine/action/pipe/button.action'; -import type { objectInteractionActionHandler } from '@engine/action/pipe/object-interaction.action'; +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 { ActorTask } from '@engine/task/impl/actor-task'; -import type { Player } from '@engine/world/actor/player/player'; +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 { soundIds } from '@engine/world/config/sound-ids'; import { logger } from '@runejs/common'; +import { take } from 'rxjs/operators'; interface Spinnable { input: number | number[]; @@ -23,8 +25,18 @@ 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], output: itemIds.crossbowString, @@ -72,111 +84,72 @@ export const openSpinningInterface: objectInteractionActionHandler = details => }); }; -/** - * 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; - } +function processSpin(player: Player, spinnable: Spinnable): boolean { + let currentItem: number; + let currentItemIndex = 0; - public execute(): void { - if (this.created === this.count) { - this.stop(); - return; - } + // Determine current input item + if (Array.isArray(spinnable.input)) { + currentItem = spinnable.input[currentItemIndex]; + } else { + currentItem = spinnable.input; + } - // 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]; + // 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 { - this.currentItem = this.spinnable.input; + const itemName = findItem(currentItem)?.name || ''; + player.sendMessage(`You don't have any ${itemName.toLowerCase()}.`); + return false; } + } - // 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; - } - } + // Process the spinning action + player.removeFirstItem(currentItem); + player.giveItem(spinnable.output); + player.skills.addExp(Skill.CRAFTING, spinnable.experience); - // 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++; - } + return true; +} +async function spinProduct(player: Player, spinnable: Spinnable, count: number): Promise { + // Early exit if count is 0 + if (count <= 0) { + return; + } - // animation plays once every two items - if (this.elapsedTicks % 6 === 0) { - this.actor.playAnimation(animationIds.spinSpinningWheel); - this.actor.outgoingPackets.playSound(soundIds.spinWool, 5); + 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, + }); + + // Play animation and sound each time + player.playAnimation(animationIds.spinSpinningWheel); + player.playSound(soundIds.spinWool, 5); + + // Process the spin + if (!processSpin(player, spinnable)) { + break; + } } - - this.elapsedTicks++; + } catch (error) { + // Queue was interrupted (movement/combat/etc) + player.sendMessage(`Spinning interrupted: ${error}`); } } -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; @@ -185,9 +158,9 @@ 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, @@ -196,36 +169,42 @@ export const buttonClicked: buttonActionHandler = details => { } 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 using WEAK queue + 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 { +export default ({ pluginId: 'rs:spinning_wheel', hooks: [ { type: 'object_interaction', objectIds: objectIds.spinningWheel, options: ['spin'], - walkTo: true, handler: openSpinningInterface, + walkTo: true, }, { type: 'button', @@ -234,4 +213,4 @@ export default { handler: buttonClicked, }, ], -}; +}); diff --git a/src/plugins/skills/mining/mining-task.ts b/src/plugins/skills/mining/mining-task.ts index c6b0b7cf8..ad95d3189 100644 --- a/src/plugins/skills/mining/mining-task.ts +++ b/src/plugins/skills/mining/mining-task.ts @@ -72,7 +72,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/chance.ts b/src/plugins/skills/woodcutting/chance.ts deleted file mode 100644 index 4bd30d5a4..000000000 --- a/src/plugins/skills/woodcutting/chance.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { randomBetween } from '@engine/util/num'; -import type { IHarvestable } from '@engine/world/config/harvestable-object'; - -/** - * 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 81a7e0d41..000000000 --- a/src/plugins/skills/woodcutting/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ObjectInteractionActionHook } from '@engine/action/pipe/object-interaction.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 758a913db..000000000 --- a/src/plugins/skills/woodcutting/woodcutting-task.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { findItem, findObject } from '@engine/config/config-handler'; -import { ActorLandscapeObjectInteractionTask } from '@engine/task/impl/actor-landscape-object-interaction-task'; -import { colors } from '@engine/util/colors'; -import { randomBetween } from '@engine/util/num'; -import { colorText } from '@engine/util/strings'; -import { activeWorld } from '@engine/world'; -import type { Player } from '@engine/world/actor/player/player'; -import { Skill } from '@engine/world/actor/skills'; -import type { IHarvestable } from '@engine/world/config/harvestable-object'; -import { getTreeFromHealthy } from '@engine/world/config/harvestable-object'; -import { soundIds } from '@engine/world/config/sound-ids'; -import { rollBirdsNestType } from '@engine/world/skill-util/harvest-roll'; -import { canInitiateHarvest } from '@engine/world/skill-util/harvest-skill'; -import { logger } from '@runejs/common'; -import type { LandscapeObject } from '@runejs/filestore'; -import { canCut } from './chance'; - -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; - } - } - - private getItemToAdd(): string | null { - this.actor.sendMessage(`Looking for item ${this.treeInfo.items}`); - if (typeof this.treeInfo.items === 'string') { - return this.treeInfo.items; - } - - // Handle weighted items - const totalWeight = this.treeInfo.items.reduce((sum, item) => sum + item.weight, 0); - let random = randomBetween(1, totalWeight); - - for (const item of this.treeInfo.items) { - random -= item.weight; - if (random <= 0) { - return item.itemConfigId; - } - } - - return null; - } - - /** - * 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 itemConfigId = this.getItemToAdd(); - if (!itemConfigId) { - logger.error('Could not determine item to add from tree'); - this.actor.sendMessage('Sorry, an error occurred. Please report this to a developer.'); - this.stop(); - return; - } - - const logItem = findItem(itemConfigId); - if (!logItem) { - logger.error(`Could not find log item with id ${itemConfigId}`); - 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 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(itemConfigId); - } - - 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..f144c8c54 --- /dev/null +++ b/src/plugins/skills/woodcutting/woodcutting.constants.ts @@ -0,0 +1,260 @@ +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 +}; + +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, + items: 'rs:logs', + level: 1, + experience: 25, + respawnLow: 27, + respawnHigh: 45, + baseChance: 70, + break: 100, + }, + { + objects: ACHEY_OBJECTS, + items: 'rs:achey_logs', + level: 1, + experience: 25, + respawnLow: 27, + respawnHigh: 45, + baseChance: 70, + break: 100, + }, + { + objects: OAK_OBJECTS, + items: 'rs:oak_logs', + level: 15, + experience: 37.5, + respawnLow: 50, + respawnHigh: 100, + baseChance: 50, + break: 100 / 8, + }, + { + objects: WILLOW_OBJECTS, + items: 'rs:willow_logs', + level: 30, + experience: 67.5, + respawnLow: 50, + respawnHigh: 100, + baseChance: 30, + break: 100 / 8, + }, + { + objects: TEAK_OBJECTS, + items: 'rs:teak_logs', + level: 35, + experience: 85, + respawnLow: 100, + respawnHigh: 150, + 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: 100, + respawnHigh: 200, + 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: 27, + respawnHigh: 45, + baseChance: 0, + break: 100 / 8, + }, + { + objects: MAHOGANY_OBJECTS, + items: 'rs:mahogany_logs', + level: 50, + experience: 125, + respawnLow: 150, + respawnHigh: 250, + baseChance: -5, + break: 100 / 8, + }, + { + objects: YEW_OBJECTS, + items: 'rs:yew_logs', + level: 60, + experience: 175, + respawnLow: 167, + respawnHigh: 300, + baseChance: -15, + break: 100 / 8, + }, + { + objects: MAGIC_OBJECTS, + items: 'rs:magic_logs', + level: 75, + experience: 250, + respawnLow: 200, + respawnHigh: 500, + 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 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; +} + +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 new file mode 100644 index 000000000..759782dd7 --- /dev/null +++ b/src/plugins/skills/woodcutting/woodcutting.plugin.ts @@ -0,0 +1,177 @@ +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, + WOODCUTTING_SOUNDS, + getPrimaryItem, + getTreeFromHealthy, + getTreeIds, + selectWeightedItem, +} from '@plugins/skills/woodcutting/woodcutting.constants'; +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)) + .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; + // 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()) { + player.sendMessage( + `Your inventory is too full to hold any more ${findItem(getPrimaryItem(treeData.items))?.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 with proper queue type + await player.tickQueue.requestTicks({ + ticks: 3, + type: QueueType.WEAK, // Explicitly specify WEAK type for skilling + useGlobalTimer: true, + }); + + // Check for success + if (calculateSuccess(player, tree, axe)) { + const loot = selectWeightedItem(treeData.items); + // Give logs and xp + if (player.giveItem(loot)) { + player.skills.addExp('woodcutting', treeData.experience); + player.sendMessage(`You get some ${findItem(loot)?.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 details => startWoodcutting(details), + }, + ], +});