From 7ffb594e731555fc1fd0f85886537b4c78e4c184 Mon Sep 17 00:00:00 2001
From: charles-m-knox <code@charlesmknox.com>
Date: Fri, 6 Sep 2024 17:04:51 -0700
Subject: [PATCH] add basic magic teleport plugin for standard spellbook

---
 src/engine/net/inbound-packets/walk.packet.ts |   4 +
 src/engine/world/actor/player/metadata.ts     |   9 +
 src/engine/world/actor/player/player.ts       |   3 +
 src/engine/world/actor/skills.ts              |  22 +-
 src/plugins/buttons/magic-teleports.plugin.ts | 280 +++++++++++++++++-
 5 files changed, 311 insertions(+), 7 deletions(-)

diff --git a/src/engine/net/inbound-packets/walk.packet.ts b/src/engine/net/inbound-packets/walk.packet.ts
index 1dcf5d895..89826de7c 100644
--- a/src/engine/net/inbound-packets/walk.packet.ts
+++ b/src/engine/net/inbound-packets/walk.packet.ts
@@ -9,6 +9,10 @@ const walkPacket = (player: Player, packet: PacketData) => {
         size -= 14;
     }
 
+    if (!player.canMove()) {
+        return;
+    }
+
     const totalSteps = Math.floor((size - 5) / 2);
 
     const firstY = buffer.get('short', 'u', 'le');
diff --git a/src/engine/world/actor/player/metadata.ts b/src/engine/world/actor/player/metadata.ts
index f8247df1f..de019e145 100644
--- a/src/engine/world/actor/player/metadata.ts
+++ b/src/engine/world/actor/player/metadata.ts
@@ -105,4 +105,13 @@ export type PlayerMetadata = {
      * The ID of the player's currently open skill guide.
      */
     activeSkillGuide: number;
+
+    /**
+     * Whether or not the player is casting a spell that immobilizes them.
+     * Different from the base Actor class's teleport metadata property. A use
+     * case for this is to prevent the player from teleporting multiple times
+     * before the teleport animation finishes (it takes a few ticks).
+     */
+    castingStationarySpell: boolean;
+
 };
diff --git a/src/engine/world/actor/player/player.ts b/src/engine/world/actor/player/player.ts
index 665f5acb5..4647d1763 100644
--- a/src/engine/world/actor/player/player.ts
+++ b/src/engine/world/actor/player/player.ts
@@ -701,6 +701,9 @@ export class Player extends Actor {
     }
 
     public canMove(): boolean {
+        if (this.metadata?.castingStationarySpell) {
+            return false;
+        }
         return true;
     }
 
diff --git a/src/engine/world/actor/skills.ts b/src/engine/world/actor/skills.ts
index d38df6e13..8d7553180 100644
--- a/src/engine/world/actor/skills.ts
+++ b/src/engine/world/actor/skills.ts
@@ -3,6 +3,7 @@ import { serverConfig } from '@server/game/game-server';
 import { gfxIds } from '@engine/world/config';
 import { Actor } from './actor';
 import { Player } from './player';
+import { QueueableTask } from '@engine/action/pipe/task/queueable-task';
 
 export enum Skill {
     ATTACK,
@@ -238,9 +239,24 @@ export class Skills extends SkillShortcuts {
                     return;
                 }
 
-                this.actor.sendMessage(`Congratulations, you just advanced a ` +
-                    `${ achievementDetails.name.toLowerCase() } level.`);
-                this.showLevelUpDialogue(skill, finalLevel);
+                /**
+                 * Note: For skills like casting magic teleports, if the
+                 * level-up dialogue is shown too quickly, it interrupts the
+                 * task processing queue - the dialogue gets shown but the
+                 * teleport doesn't always finish.
+                 *
+                 * Queueing the level-up dialog 1 tick later will result in the
+                 * dialogue being shown after other events get processed on the
+                 * tick that the xp drop occurred.
+                 */
+                this.actor.enqueueBaseTask(new QueueableTask([], this.actor, () => {
+                    (this.actor as Player).sendMessage(`Congratulations, you just advanced a ` + `${ achievementDetails.name.toLowerCase() } level.`);
+                    this.showLevelUpDialogue(skill, finalLevel);
+                    return {
+                        callbackResult: false,
+                        shouldContinueLooping: false,
+                    }
+                } , null, null))
             }
         }
     }
diff --git a/src/plugins/buttons/magic-teleports.plugin.ts b/src/plugins/buttons/magic-teleports.plugin.ts
index 79c8ef14e..de1cc023c 100644
--- a/src/plugins/buttons/magic-teleports.plugin.ts
+++ b/src/plugins/buttons/magic-teleports.plugin.ts
@@ -4,9 +4,18 @@ import { Position } from '@engine/world/position';
 import { animationIds } from '@engine/world/config/animation-ids';
 import { soundIds } from '@engine/world/config/sound-ids';
 import { gfxIds } from '@engine/world/config/gfx-ids';
+import { itemIds } from '@engine/world/config/item-ids';
+import { Item } from '@engine/world/items/item';
+import { widgets } from '@engine/config/config-handler';
+import { TravelLocation } from '@engine/world/config';
+import { activeWorld } from '@engine/world';
+import { Skill } from '@engine/world/actor/skills';
+import { openHouse } from '@plugins/skills/construction/house';
+import { QueueableTask } from '@engine/action/pipe/task/queueable-task';
 
 enum Teleports {
     Home = 591,
+    House = 581,
     Varrock = 12,
     Lumbridge = 15,
     Falador = 18,
@@ -17,10 +26,121 @@ enum Teleports {
     Ape_atoll = 569
 }
 
+/**
+ * Keeps track of the cost of performing basic teleport spells.
+ *
+ * As of 2024-09-02 the magic system isn't fully implemented, so there isn't
+ * really a centralized location for storing and processing spell costs.
+ * Defining it here is the alternative.
+ *
+ * If needed, it can be exported, but it's not exported in order to keep this
+ * plugin self-contained.
+ */
+const MagicCosts: Record<number, MagicCost> = {
+    [Teleports.Varrock]: {
+        [itemIds.runes.air]: 3,
+        [itemIds.runes.law]: 1,
+        [itemIds.runes.fire]: 1,
+    },
+    [Teleports.Lumbridge]: {
+        [itemIds.runes.air]: 3,
+        [itemIds.runes.law]: 1,
+        [itemIds.runes.earth]: 1,
+    },
+    [Teleports.Falador]: {
+        [itemIds.runes.air]: 3,
+        [itemIds.runes.law]: 1,
+        [itemIds.runes.water]: 1,
+    },
+    [Teleports.House]: {
+        [itemIds.runes.air]: 1,
+        [itemIds.runes.law]: 1,
+        [itemIds.runes.earth]: 1,
+    },
+    [Teleports.Camelot]: {
+        [itemIds.runes.air]: 5,
+        [itemIds.runes.law]: 1,
+    },
+    [Teleports.Ardougne]: {
+        [itemIds.runes.water]: 2,
+        [itemIds.runes.law]: 2,
+    },
+    [Teleports.Watchtower]: {
+        [itemIds.runes.law]: 2,
+        [itemIds.runes.earth]: 2,
+    },
+    [Teleports.Trollheim]: {
+        [itemIds.runes.law]: 2,
+        [itemIds.runes.fire]: 2,
+    },
+    [Teleports.Ape_atoll]: {
+        [itemIds.runes.fire]: 2,
+        [itemIds.runes.law]: 2,
+        [itemIds.runes.water]: 2,
+        [itemIds.banana]: 1,
+    },
+}
+
+/**
+ * Mapping of the various teleport locations. Some are usable directly from
+ * the `activeWorld.travelLocations` lookups, but others are not.
+ */
+const TeleportLocations: Record<number, Position> = {
+    [Teleports.Home]: new Position(3218, 3218),
+    [Teleports.Varrock]: new Position(3212, 3424),
+    [Teleports.Lumbridge]: new Position(3224, 3218),
+    [Teleports.Falador]: new Position(2965, 3380),
+    [Teleports.Camelot]: new Position(2757, 3478),
+    [Teleports.Ardougne]: new Position(2662, 3307),
+    [Teleports.Watchtower]: new Position(2934, 4714, 2),
+    [Teleports.Trollheim]: (activeWorld.travelLocations.find('Trollheim') as TravelLocation).position,
+    [Teleports.Ape_atoll]: new Position(2798, 2798, 1),
+}
+
+const TeleportXP: Record<number, number> = {
+    [Teleports.Varrock]: 35,
+    [Teleports.Lumbridge]: 41,
+    [Teleports.Falador]: 48,
+    [Teleports.House]: 30,
+    [Teleports.Camelot]: 55.5,
+    [Teleports.Ardougne]: 61,
+    [Teleports.Watchtower]: 68,
+    [Teleports.Trollheim]: 68,
+    [Teleports.Ape_atoll]: 74,
+}
+
 const buttonIds: number[] = [
-    591, // Home Teleport
+    Teleports.Home,
+    Teleports.Varrock,
+    Teleports.Lumbridge,
+    Teleports.Falador,
+    Teleports.House,
+    Teleports.Camelot,
+    Teleports.Ardougne,
+    Teleports.Watchtower,
+    Teleports.Trollheim,
+    Teleports.Ape_atoll,
 ];
 
+function queueTeleport(player: Player, pos: Position) {
+    player.enqueueBaseTask(new QueueableTask([], player, () => {
+        player.teleport(pos);
+        player.metadata.castingStationarySpell = false;
+        return {
+            callbackResult: false,
+            shouldContinueLooping: false,
+        }
+    }, null, null))
+}
+
+/**
+ * Casts the home teleport spell (not their player owned home).
+ *
+ * @param elapsedTicks A counter of the number of elapsed ticks since the
+ * teleport started. Used to increment through the teleporting animation up
+ * until the actual teleport occurs.
+ * @returns `true` once the teleport finishes, `false` until it finishes
+ */
 function homeTeleport(player: Player, elapsedTicks: number): boolean {
     if (elapsedTicks === 0) {
         player.playAnimation(animationIds.homeTeleportDraw);
@@ -42,14 +162,133 @@ function homeTeleport(player: Player, elapsedTicks: number): boolean {
         player.playAnimation(animationIds.homeTeleport);
         player.playGraphics({ id: gfxIds.homeTeleport, delay: 0, height: 0 });
     } else if (elapsedTicks === 22) {
-        player.teleport(new Position(3218, 3218));
+        queueTeleport(player, TeleportLocations[Teleports.Home])
+        return true;
+    }
+
+    return false;
+}
+
+type MagicCost = Record<number, number>;
+
+/**
+ * Determines if the player currently has infinite quantities of a resource,
+ * such as a fire staff for fire runes.
+ *
+ * @param resource The item ID for the resource, such as a fire rune.
+ */
+function hasInfinite(player: Player, resource: number): boolean {
+    switch (resource) {
+        case itemIds.runes.air: {
+            if (player.equipment.has(itemIds.staffs.air)) {
+                return true;
+            }
+            break;
+        }
+        case itemIds.runes.fire: {
+            if (player.equipment.has(itemIds.staffs.fire)) {
+                return true;
+            }
+            break;
+        }
+        case itemIds.runes.water: {
+            if (player.equipment.has(itemIds.staffs.water)) {
+                return true;
+            }
+            break;
+        }
+        case itemIds.runes.earth: {
+            if (player.equipment.has(itemIds.staffs.earth)) {
+                return true;
+            }
+            break;
+        }
+    }
+
+    return false;
+}
+
+/**
+ * Deducts the cost of the spell from the player's inventory.
+ *
+ * @returns `false` if the player lacks the required runes
+ */
+function expenseMagic(player: Player, cost: MagicCost): boolean {
+    if (!cost) return true;
+
+    const indexesToUpdate: number[] = [];
+    const itemsToUpdate: Record<number, Item> = [];
+
+    for (const requiredItemId in cost) {
+        const itemId: number = Number(requiredItemId);
+        if (hasInfinite(player, itemId)) {
+            continue;
+        }
+
+        const itemIndex: number = player.inventory.findIndex(itemId);
+        if (itemIndex < 0) {
+            return false;
+        }
+
+        const newItem: Item = {
+            amount: player.inventory.amount(itemId) - cost[requiredItemId],
+            itemId: itemId,
+        }
+
+        if (newItem.amount < 0) {
+            return false;
+        }
+
+        itemsToUpdate[itemIndex] = newItem;
+        indexesToUpdate.push(itemIndex);
+    }
+
+    for (let i = 0; i < indexesToUpdate.length; i++) {
+        if (itemsToUpdate[indexesToUpdate[i]].amount === 0) {
+            player.inventory.remove(indexesToUpdate[i])
+        } else {
+            player.inventory.set(indexesToUpdate[i], itemsToUpdate[indexesToUpdate[i]]);
+        }
+    }
+
+    return true;
+}
+
+function genericTeleport(player: Player, elapsedTicks: number, target: Position, teleportId?: number): boolean {
+    if (elapsedTicks === 0) {
+        player.playAnimation(animationIds.teleport)
+        player.outgoingPackets.playSound(soundIds.teleport, 10);
+        player.playGraphics({ id: gfxIds.teleport, delay: 0, height: 100 });
+    } else if (elapsedTicks === 3) {
+        switch (teleportId) {
+            case Teleports.House: {
+                openHouse(player);
+                break;
+            }
+            default: {
+                queueTeleport(player, target)
+
+                // warning: undefined xp values cause the xp to reset to 0,
+                // so make sure to always assert that it's defined
+                if (teleportId && TeleportXP[teleportId]) {
+                    player.enqueueBaseTask(new QueueableTask([], player, () => {
+                        player.skills.addExp(Skill.MAGIC, TeleportXP[teleportId])
+                        return { callbackResult: false, shouldContinueLooping: false };
+                    }, null, null));
+                }
+                break;
+            }
+        }
+        player.playAnimation(animationIds.reset)
         return true;
     }
 
     return false;
 }
 
-export const activate = (task: TaskExecutor<ButtonAction>, elapsedTicks: number = 0) => {
+const insufficient = 'You do not have enough runes to cast this spell.';
+
+const activate = (task: TaskExecutor<ButtonAction>, elapsedTicks: number = 0) => {
     const { player, buttonId } = task.actionData;
 
     let completed: boolean = false;
@@ -58,9 +297,42 @@ export const activate = (task: TaskExecutor<ButtonAction>, elapsedTicks: number
         case Teleports.Home:
             completed = homeTeleport(player, elapsedTicks);
             break;
+        case Teleports.Varrock:
+        case Teleports.Lumbridge:
+        case Teleports.Falador:
+        case Teleports.House:
+        case Teleports.Camelot:
+        case Teleports.Ardougne:
+        case Teleports.Watchtower:
+        case Teleports.Trollheim:
+        case Teleports.Ape_atoll: {
+            if (elapsedTicks === 0) {
+                // prevents the player from spamming the spell
+                if (player.metadata?.castingStationarySpell) {
+                    player.sendMessage('You are already teleporting.');
+                    task.stop();
+                    return;
+                }
+
+                player.metadata.castingStationarySpell = true;
+
+                if (!expenseMagic(player, MagicCosts[buttonId])) {
+                    player.sendMessage(insufficient);
+                    completed = true;
+                    break;
+                }
+
+                player.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, player.inventory);
+            }
+
+            completed = genericTeleport(player, elapsedTicks, TeleportLocations[buttonId], buttonId);
+
+            break;
+        }
     }
 
-    if(completed) {
+    if (completed) {
+        player.metadata.castingStationarySpell = false;
         task.stop();
     }
 };