diff --git a/README.md b/README.md index e30b9ed7c..f3df30286 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ [![RuneJS Discord Server](https://img.shields.io/discord/678751302297059336?label=RuneJS%20Discord&logo=discord)](https://discord.gg/5P74nSh) -![Rune.JS](https://i.imgur.com/osF9OSD.png) +![RuneJS](https://i.imgur.com/osF9OSD.png) -# Rune.JS +# RuneJS -Rune.JS is a RuneScape game server written entirely using TypeScript and JavaScript. The aim of this project is to create a game server that is both fun and easy to use, while also providing simple content development systems. +RuneJS is a RuneScape game server written entirely using TypeScript and JavaScript. The aim of this project is to create a game server that is both fun and easy to use, while also providing simple content development systems. -Currently the server is set up for the 377 revision of the game. There are not any plans to convert it to other versions at this time, though that could very well change. Any regular 377 client with RSA enabled should work with Rune.JS. +Currently the server is set up for the 435 revision of the game, which was a game update made on October 31st, 2006. There are not any plans to convert it to other versions at this time. ## Features +- RSA and ISAAC ciphering support. - Login & input/output packet handling. - Player saving/loading via JSON files. - Multiplayer support. @@ -22,10 +23,11 @@ Currently the server is set up for the 377 revision of the game. There are not a - Player equipment with item bonuses & weight. - NPC spawning and updating. - NPC spawn loading via YAML configuration. -- Player & NPC pathing validation via collision and tile maps generated from the game cache. -- Player client settings saving and loading. +- Player & NPC pathing validation via collision and tile maps generated from the 377 game cache. - A basic REST service for polling logged in users. +- Full functional update server. - A diverse TypeScript plugin system for easily writing new content based off of in-game actions. +- Flexible quest and dialogue systems for easy content development. ## Usage @@ -41,14 +43,16 @@ The game server will spin up and be accessible via port 43594. The REST service ## Cache Parsing -A separate package was created that Rune.JS uses to parse the 377 game cache. This package parses item definitions, landscape object definitions, map region tiles, and map region landscape objects. The Rune.JS `cache-parser` package can be found here: +A separate package was created that RuneJS uses to parse the 435 game cache. This package decodes item definitions, npc definitions, location object definitions, widgets, sprites, and map data (tiles and location objects) for any implementing app to make use of. + +The RuneJS `cache-parser` package can be found here: - [Github: rune-js/cache-parser](https://github.com/rune-js/cache-parser) - [NPM: @runejs/cache-parser](https://www.npmjs.com/package/@runejs/cache-parser) ## REST API -Online players can be polled via the REST protocol for web applications. An accompanying server control panel UI is panned utilizing VueJS that will point to this REST service. +Online players can be polled via the REST protocol for web applications. ##### API Endpoints: @@ -59,16 +63,12 @@ Online players can be polled via the REST protocol for web applications. An acco ## Aditional Information -#### Supported 377 Clients +#### Supported 435 Clients -Rune.JS should support any vanilla RuneScape 377 client and game cache, such as: +RuneJS supports the 435 RuneScape game client being renamed by [Promises](https://github.com/Promises) and [TheBlackParade](https://github.com/TheBlackParade): -- [refactored-client-377](https://github.com/Promises/refactored-client-377) by [Promises](https://github.com/Promises) -- [Runescape 377 Web client](https://github.com/reinismu/runescape-web-client-377) by [reinismu](https://github.com/reinismu) -- Any old 377 deobfuscated client +- [refactored-client-435](https://github.com/Promises/refactored-client-435) #### Update Server -To use Rune.JS, your 377 client's update server will either need to be disabled or you'll have to spin up your own update server alongside Rune.JS, as it does not include an update server of it's own. - -We highly recommend using [JagCached](https://github.com/apollo-rsps/jagcached), a RuneScape update server written by [Graham Edgecombe](https://github.com/apollo-rsps/jagcached/commits?author=grahamedgecombe). +RuneJS provides a fully working update server for the 435 client to use. The update server runs alongside the regular game server using the same port, so no additional configuration is required. Simply start the server and then your game client. diff --git a/cache/main_file_cache.dat0 b/cache/main_file_cache.dat0 new file mode 100644 index 000000000..5c9b9beb6 Binary files /dev/null and b/cache/main_file_cache.dat0 differ diff --git a/cache/main_file_cache.dat1 b/cache/main_file_cache.dat1 new file mode 100644 index 000000000..c70119184 Binary files /dev/null and b/cache/main_file_cache.dat1 differ diff --git a/cache/main_file_cache.dat b/cache/main_file_cache.dat2 similarity index 62% rename from cache/main_file_cache.dat rename to cache/main_file_cache.dat2 index 3ef7685b2..e9ec0add0 100644 Binary files a/cache/main_file_cache.dat and b/cache/main_file_cache.dat2 differ diff --git a/cache/main_file_cache.idx0 b/cache/main_file_cache.idx0 index 6e4330f76..81ab4c13e 100644 Binary files a/cache/main_file_cache.idx0 and b/cache/main_file_cache.idx0 differ diff --git a/cache/main_file_cache.idx1 b/cache/main_file_cache.idx1 index 86fc5ac97..47bd5574e 100644 Binary files a/cache/main_file_cache.idx1 and b/cache/main_file_cache.idx1 differ diff --git a/cache/main_file_cache.idx10 b/cache/main_file_cache.idx10 new file mode 100644 index 000000000..66cdc8ca0 Binary files /dev/null and b/cache/main_file_cache.idx10 differ diff --git a/cache/main_file_cache.idx11 b/cache/main_file_cache.idx11 new file mode 100644 index 000000000..06f1b505c Binary files /dev/null and b/cache/main_file_cache.idx11 differ diff --git a/cache/main_file_cache.idx12 b/cache/main_file_cache.idx12 new file mode 100644 index 000000000..9cb8e27fd Binary files /dev/null and b/cache/main_file_cache.idx12 differ diff --git a/cache/main_file_cache.idx2 b/cache/main_file_cache.idx2 index 80b35f75e..ab5cb9543 100644 Binary files a/cache/main_file_cache.idx2 and b/cache/main_file_cache.idx2 differ diff --git a/cache/main_file_cache.idx255 b/cache/main_file_cache.idx255 new file mode 100644 index 000000000..82d912cfe Binary files /dev/null and b/cache/main_file_cache.idx255 differ diff --git a/cache/main_file_cache.idx3 b/cache/main_file_cache.idx3 index d467e18bb..4e4d422e5 100644 Binary files a/cache/main_file_cache.idx3 and b/cache/main_file_cache.idx3 differ diff --git a/cache/main_file_cache.idx4 b/cache/main_file_cache.idx4 index a1afef6ab..bdabc4fef 100644 Binary files a/cache/main_file_cache.idx4 and b/cache/main_file_cache.idx4 differ diff --git a/cache/main_file_cache.idx5 b/cache/main_file_cache.idx5 new file mode 100644 index 000000000..44ddb26a1 Binary files /dev/null and b/cache/main_file_cache.idx5 differ diff --git a/cache/main_file_cache.idx6 b/cache/main_file_cache.idx6 new file mode 100644 index 000000000..588fc8581 Binary files /dev/null and b/cache/main_file_cache.idx6 differ diff --git a/cache/main_file_cache.idx7 b/cache/main_file_cache.idx7 new file mode 100644 index 000000000..937bf50b3 Binary files /dev/null and b/cache/main_file_cache.idx7 differ diff --git a/cache/main_file_cache.idx8 b/cache/main_file_cache.idx8 new file mode 100644 index 000000000..dad22d1ae Binary files /dev/null and b/cache/main_file_cache.idx8 differ diff --git a/cache/main_file_cache.idx9 b/cache/main_file_cache.idx9 new file mode 100644 index 000000000..b7a72248d Binary files /dev/null and b/cache/main_file_cache.idx9 differ diff --git a/data/config/item-data.yaml b/data/config/item-data.yaml index d804c343a..403cbcc3a 100644 --- a/data/config/item-data.yaml +++ b/data/config/item-data.yaml @@ -1,3 +1,368 @@ +# Fletching +- id: 9783 + desc: fletching cape + canTrade: false + weight: 0.050 + equipment: + slot: BACK +- id: 9784 + desc: fletching cape (t) + canTrade: false + weight: 0.050 + equipment: + slot: BACK +- id: 9785 + desc: fletching hood + canTrade: false + equipment: + slot: HEAD +# Attack +- id: 9747 + desc: attack cape + canTrade: false + equipment: + slot: BACK +- id: 9748 + desc: attack cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9749 + desc: attack hood + canTrade: false + equipment: + slot: HEAD +# Strength +- id: 9750 + desc: strength cape + canTrade: false + equipment: + slot: BACK +- id: 9751 + desc: strength cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9752 + desc: strength hood + canTrade: false + equipment: + slot: HEAD +# Defence +- id: 9753 + desc: defence cape + canTrade: false + equipment: + slot: BACK +- id: 9754 + desc: defence cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9755 + desc: defence hood + canTrade: false + equipment: + slot: HEAD +# Hitpoints +- id: 9768 + desc: hitpoints cape + canTrade: false + equipment: + slot: BACK +- id: 9769 + desc: hitpoints cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9770 + desc: hitpoints hood + canTrade: false + equipment: + slot: HEAD +# Ranging +- id: 9756 + desc: ranging cape + canTrade: false + equipment: + slot: BACK +- id: 9757 + desc: ranging cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9758 + desc: ranging hood + canTrade: false + equipment: + slot: HEAD +# Prayer +- id: 9759 + desc: prayer cape + canTrade: false + equipment: + slot: BACK +- id: 9760 + desc: prayer cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9761 + desc: prayer hood + canTrade: false + equipment: + slot: HEAD +# Magic +- id: 9762 + desc: magic cape + canTrade: false + equipment: + slot: BACK +- id: 9763 + desc: magic cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9764 + desc: magic hood + canTrade: false + equipment: + slot: HEAD +# Runecraft +- id: 9765 + desc: runecraft cape + canTrade: false + equipment: + slot: BACK +- id: 9766 + desc: runecraft cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9767 + desc: runecraft hood + canTrade: false + equipment: + slot: HEAD +# Agility +- id: 9771 + desc: agility cape + canTrade: false + equipment: + slot: BACK +- id: 9772 + desc: agility cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9773 + desc: agility hood + canTrade: false + equipment: + slot: HEAD +# Herblore +- id: 9774 + desc: herblore cape + canTrade: false + equipment: + slot: BACK +- id: 9775 + desc: herblore cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9776 + desc: herblore hood + canTrade: false + equipment: + slot: HEAD +# Thieving +- id: 9777 + desc: thieving cape + canTrade: false + equipment: + slot: BACK +- id: 9778 + desc: thieving cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9779 + desc: thieving hood + canTrade: false + equipment: + slot: HEAD +# Crafting +- id: 9780 + desc: crafting cape + canTrade: false + equipment: + slot: BACK +- id: 9781 + desc: crafting cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9782 + desc: crafting hood + canTrade: false + equipment: + slot: HEAD +# Slayer +- id: 9786 + desc: slayer cape + canTrade: false + equipment: + slot: BACK +- id: 9787 + desc: slayer cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9788 + desc: slayer hood + canTrade: false + equipment: + slot: HEAD +# Construction +- id: 9789 + desc: construction cape + canTrade: false + equipment: + slot: BACK +- id: 9790 + desc: construction cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9791 + desc: construction hood + canTrade: false + equipment: + slot: HEAD +# Mining +- id: 9792 + desc: mining cape + canTrade: false + equipment: + slot: BACK +- id: 9793 + desc: mining cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9794 + desc: mining hood + canTrade: false + equipment: + slot: HEAD +# Smithing +- id: 9795 + desc: smithing cape + canTrade: false + equipment: + slot: BACK +- id: 9796 + desc: smithing cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9797 + desc: smithing hood + canTrade: false + equipment: + slot: HEAD +# Fishing +- id: 9798 + desc: smithing cape + canTrade: false + equipment: + slot: BACK +- id: 9799 + desc: smithing cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9800 + desc: smithing hood + canTrade: false + equipment: + slot: HEAD +# Cooking +- id: 9801 + desc: cooking cape + canTrade: false + equipment: + slot: BACK +- id: 9802 + desc: cooking cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9803 + desc: cooking hood + canTrade: false + equipment: + slot: HEAD +# Firemaking +- id: 9804 + desc: firemaking cape + canTrade: false + equipment: + slot: BACK +- id: 9805 + desc: firemaking cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9806 + desc: firemaking hood + canTrade: false + equipment: + slot: HEAD +# Woodcutting +- id: 9807 + desc: woodcutting cape + canTrade: false + equipment: + slot: BACK +- id: 9808 + desc: woodcutting cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9809 + desc: woodcutting hood + canTrade: false + equipment: + slot: HEAD +# Farming +- id: 9810 + desc: farming cape + canTrade: false + equipment: + slot: BACK +- id: 9811 + desc: farming cape (t) + canTrade: false + equipment: + slot: BACK +- id: 9812 + desc: farming hood + canTrade: false + equipment: + slot: HEAD +# Quest +- id: 9813 + desc: quest point cape + canTrade: false + equipment: + slot: BACK +- id: 9814 + desc: quest point hood + canTrade: false + equipment: + slot: HEAD - id: 590 desc: tinderbox canTrade: true @@ -15,6 +380,28 @@ equipment: slot: HEAD helmetType: HAT +- id: 1061 + desc: leather boots + canTrade: true + weight: 0.34 + alchemy: + high: 3 + low: 3 + equipment: + slot: BOOTS + bonuses: + offencive: + stab: 0 + slash: 0 + crush: 0 + magic: 0 + ranged: 0 + defencive: + stab: 0 + slash: 1 + crush: 1 + magic: 0 + ranged: 0 - id: 1079 desc: rune platelegs canTrade: true diff --git a/data/config/npc-spawns.yaml b/data/config/npc-spawns.yaml index 90aa3f1a4..0c0f76ea7 100644 --- a/data/config/npc-spawns.yaml +++ b/data/config/npc-spawns.yaml @@ -22,3 +22,55 @@ x: 3230 y: 3203 radius: 1 +- npcId: 540 + x: 3288 + y: 3212 + radius: 1 +- npcId: 3807 + x: 3253 + y: 3274 + radius: 1 +- npcId: 3579 + x: 3197 + y: 3262 + radius: 10 +- npcId: 43 + x: 3199 + y: 3267 + radius: 10 +- npcId: 43 + x: 3203 + y: 3271 + radius: 10 +- npcId: 43 + x: 3199 + y: 3273 + radius: 10 +- npcId: 43 + x: 3208 + y: 3273 + radius: 10 +- npcId: 43 + x: 3209 + y: 3260 + radius: 10 +- npcId: 278 + x: 3210 + y: 3215 + radius: 5 +- npcId: 545 + x: 3320 + y: 3194 + radius: 3 +- npcId: 542 + x: 3317 + y: 3174 + radius: 3 +- npcId: 544 + x: 3315 + y: 3161 + radius: 3 +- npcId: 3806 + x: 3168 + y: 3306 + radius: 3 diff --git a/data/config/server-config-default.yaml b/data/config/server-config-default.yaml index b3e4fec88..f3f8d54bc 100644 --- a/data/config/server-config-default.yaml +++ b/data/config/server-config-default.yaml @@ -1,5 +1,6 @@ rsaMod: '119568088839203297999728368933573315070738693395974011872885408638642676871679245723887367232256427712869170521351089799352546294030059890127723509653145359924771433131004387212857375068629466435244653901851504845054452735390701003613803443469723435116497545687393297329052988014281948392136928774011011998343' rsaExp: '12747337179295870166838611986189126026507945904720545965726999254744592875817063488911622974072289858092633084100280214658532446654378876853112046049506789703022033047774294965255097838909779899992870910011426403494610880634275141204442441976355383839981584149269550057129306515912021704593400378690444280161' -host: '127.0.0.1' +host: '0.0.0.0' port: 43594 showWelcome: true +expRate: 1 diff --git a/data/config/shops.yaml b/data/config/shops.yaml index bab4e4121..8fcc5833d 100644 --- a/data/config/shops.yaml +++ b/data/config/shops.yaml @@ -68,3 +68,131 @@ text: Mithril battleaxe price: 1690 amountInStock: 1 +- identification: ALKHARID_GEM_TRADER + name: Gem Trader + items: + - id: 1623 + text: Uncut sapphire + price: 25 + amountInStock: 1 + - id: 1621 + text: Uncut emerald + price: 50 + amountInStock: 1 + - id: 1619 + text: Uncut ruby + price: 100 + amountInStock: 0 + - id: 1617 + text: Uncut diamond + price: 200 + amountInStock: 0 + - id: 1607 + text: Sapphire + price: 250 + amountInStock: 1 + - id: 1605 + text: Emerald + price: 500 + amountInStock: 1 + - id: 1603 + text: Ruby + price: 1000 + amountInStock: 0 + - id: 1601 + text: Diamond + price: 2000 + amountInStock: 0 +- identification: DOMMIK_CRAFTING_STORE + name: Dommik's Crafting Store + items: + - id: 1755 + text: Chisel + price: 1 + amountInStock: 2 + - id: 1592 + text: Ring mould + price: 5 + amountInStock: 4 + - id: 1597 + text: Necklace mould + price: 5 + amountInStock: 2 + - id: 1595 + text: Amulet mould + price: 5 + amountInStock: 2 + - id: 1733 + text: Needle + price: 1 + amountInStock: 3 + - id: 1734 + text: Thread + price: 1 + amountInStock: 100 + - id: 1599 + text: Holy mould + price: 5 + amountInStock: 3 + - id: 2976 + text: Sickle mould + price: 10 + amountInStock: 6 + - id: 5523 + text: Tiara mould + price: 100 + amountInStock: 10 +- identification: LOUIES_ARMOURED_LEGS_BAZAR + name: Louie's Armoured Legs Bazaar + items: + - id: 1075 + text: Bronze platelegs + price: 80 + amountInStock: 5 + - id: 1067 + text: Iron platelegs + price: 280 + amountInStock: 3 + - id: 1069 + text: Steel platelegs + price: 1000 + amountInStock: 2 + - id: 1077 + text: Black platelegs + price: 1920 + amountInStock: 1 + - id: 1071 + text: Mithril platelegs + price: 2600 + amountInStock: 1 + - id: 1073 + text: Adamant platelegs + price: 6400 + amountInStock: 1 +- identification: RANAELS_SUPER_SKIRT_STORE + name: Ranael's Super Skirt Store + items: + - id: 1087 + text: Bronze plateskirt + price: 80 + amountInStock: 5 + - id: 1081 + text: Iron plateskirt + price: 280 + amountInStock: 3 + - id: 1083 + text: Steel plateskirt + price: 1000 + amountInStock: 2 + - id: 1089 + text: Black plateskirt + price: 1920 + amountInStock: 1 + - id: 1085 + text: Mithril plateskirt + price: 2600 + amountInStock: 1 + - id: 1091 + text: Adamant plateskirt + price: 6400 + amountInStock: 1 diff --git a/data/config/skill-guides.yaml b/data/config/skill-guides.yaml index b83be81e9..ec69271bf 100644 --- a/data/config/skill-guides.yaml +++ b/data/config/skill-guides.yaml @@ -1,5 +1,6 @@ -- id: 8654 +- id: 118 name: Attack + members: false subGuides: - name: Weapons lines: @@ -54,3 +55,692 @@ - itemId: 4726 text: "Members: Guthan's warspear" level: 70 + +- id: 122 + name: Agility + members: true + subGuides: + - name: Courses + lines: + - itemId: 2150 + text: "Gnome Stronghold Agility Course" + level: 1 + - itemId: 751 + text: "Gnomeball Game" + level: 1 + - itemId: 1061 + text: "Werewolf Skullball game" + level: 25 + - itemId: 6970 + text: "Agility Pyramid" + level: 30 + - itemId: 1365 + text: "Barbarian Outpost Agility Course" + level: 35 + - itemId: 4024 + text: "Ape Atoll Agility Course" + level: 48 + - itemId: 553 + text: "Wilderness Course" + level: 52 + - itemId: 1379 + text: "Werewolf Agility Course" + level: 60 + - name: Areas + lines: + - itemId: 6518 + text: "Rope-swing to Moss Giant Island" + level: 10 + - itemId: 6518 + text: "Stepping stones in Karamja Dungeon" + level: 12 + - itemId: 6518 + text: "Monkey bars under Edgeville" + level: 15 + - itemId: 6520 + text: "Pipe contortion in Karamja Dungeon" + level: 22 + - itemId: 6518 + text: "Stepping stones in south-eastern Karamja" + level: 30 + - itemId: 6520 + text: "Pipe contortion in Karamja Dungeon" + level: 34 + - itemId: 6519 + text: "Elf area log balance" + level: 45 + - itemId: 6520 + text: "Contortion in Yanille Dungeon small room" + level: 49 + - itemId: 6520 + text: "Access the God Wars Dungeon area via the \\nAgility route" + level: 60 + - itemId: 6521 + text: "Yanille Dungeon's rubble climb" + level: 67 + - itemId: 6521 + text: "Enter the Saradomins area of the \\nGod Wars Dungeon" + level: 70 + - name: Shortcuts + lines: + - itemId: 6517 + text: "Falador Agility shortcut" + level: 5 + - itemId: 6514 + text: "Jump fence south of Varrock" + level: 13 + - itemId: 6516 + text: "Yanille Agility shortcut" + level: 16 + - itemId: 6515 + text: "Coal Truck log balance" + level: 20 + - itemId: 6516 + text: "Falador Agility shortcut" + level: 26 + - itemId: 6515 + text: "Draynor Manor stones to Champions' Guild" + level: 31 + - itemId: 6515 + text: "Ardougne log balance shortcut" + level: 33 + - itemId: 6517 + text: "Gnome Stronghold shortcut" + level: 37 + - itemId: 6517 + text: "Al Kahrid Mining pit cliffside scramble" + level: 38 + - itemId: 6517 + text: "Trollheim easy cliffside scramble" + level: 41 + - itemId: 6516 + text: "Dwarven Mine narrow crevice" + level: 42 + - itemId: 6517 + text: "Trollheim medium cliffside scramble" + level: 43 + - itemId: 6517 + text: "Trollheim advanced cliffside scramble" + level: 44 + - itemId: 6516 + text: "Cosmic Temple - narrow walkway" + level: 46 + - itemId: 6516 + text: "Deep Wilderness - narrow tunnel" + level: 46 + - itemId: 6517 + text: "Trollheim hard cliffside scramble" + level: 47 + - itemId: 6516 + text: "Pipe from Edgeville dungeon to Varrock Sewers" + level: 51 + - itemId: 6517 + text: "Port Phasmatys ectopool shortcut" + level: 58 + - itemId: 6514 + text: "Elven overpass easy cliffside scamble" + level: 59 + - itemId: 6517 + text: "Slayer Tower medium spiked chain climb" + level: 61 + - itemId: 6517 + text: "Taverley dungeon lesser demon fence shortcut" + level: 63 + - itemId: 6517 + text: "Trollheim Wilderness route" + level: 64 + - itemId: 6517 + text: "Temple on the Salve to Morytania shortcut" + level: 65 + - itemId: 6517 + text: "Elven overpass medium cliffside scramble" + level: 68 + - itemId: 6516 + text: "Taverley Dungeon short-cuts to blue dragons" + level: 70 + - itemId: 6517 + text: "Slayer Tower advanced spiked chain" + level: 71 + - itemId: 6514 + text: "Taverley Dungeon spiked blades jump" + level: 80 + - itemId: 6514 + text: "Fremennik Slayer Dungeon spiked blades jump" + level: 81 + - itemId: 6514 + text: "Brimhaven Dungeon eastern stepping stones" + level: 83 + - itemId: 6516 + text: "Iorwerth southern shortcut" + level: 84 +- id: 139 + name: Woodcutting + members: false + subGuides: + - name: Trees + lines: + - itemId: 1511 + text: Normal trees + level: 1 + - itemId: 2862 + text: Achey trees + level: 1 + - itemId: 1521 + text: Oak trees + level: 15 + - itemId: 1519 + text: Willow trees + level: 30 + - itemId: 6333 + text: Teak trees + level: 35 + - itemId: 1517 + text: Maple trees + level: 45 + - itemId: 6332 + text: Mahogany trees + level: 50 + - itemId: 1515 + text: Yew trees + level: 60 + - itemId: 1513 + text: Magic trees + level: 75 + - name: Axes + lines: + - itemId: 1351 + text: Bronze axe + level: 1 + - itemId: 1349 + text: Iron axe + level: 1 + - itemId: 1353 + text: Steel axe + level: 6 + - itemId: 1361 + text: Black axe + level: 11 + - itemId: 1355 + text: Mithril axe + level: 21 + - itemId: 1357 + text: Adamant axe + level: 31 + - itemId: 1359 + text: Rune axe + level: 41 + - itemId: 6739 + text: Dragon axe + level: 61 + - name: Other + lines: + - itemId: 0 + text: Missing content + level: 1 +- id: 129 + name: Cooking + members: false + subGuides: + - name: Meats + lines: + - itemId: 2142 + text: Meat + level: 1 + - itemId: 315 + text: Shrimp + level: 1 + - itemId: 2140 + text: Chicken + level: 1 + - itemId: 319 + text: Anchovies + level: 1 + - itemId: 325 + text: Sardine + level: 1 + - itemId: 3142 + text: 'Members: Karambwan' + level: 1 + - itemId: 1883 + text: Ugthanki kebab + level: 1 + - itemId: 345 + text: Herring + level: 5 + - itemId: 353 + text: Mackerel + level: 10 + - itemId: 3369 + text: 'Members: Thin snail' + level: 12 + - itemId: 335 + text: Trout + level: 15 + - itemId: 3371 + text: 'Members: Lean snail' + level: 17 + - itemId: 339 + text: 'Members: Cod' + level: 18 + - itemId: 351 + text: Pike + level: 20 + - itemId: 329 + text: Salmon + level: 25 + - itemId: 361 + text: Tuna + level: 30 + - itemId: 7228 + text: 'Members: Roasted chompy' + level: 30 + - itemId: 7530 + text: 'Members: Fishcakes' + level: 31 + - itemId: 5003 + text: 'Members: Cave eel' + level: 38 + - itemId: 379 + text: Lobster + level: 40 + - itemId: 397 + text: 'Members: Jubbly' + level: 41 + - itemId: 365 + text: 'Members: Bass' + level: 43 + - itemId: 373 + text: Swordfish + level: 45 + - itemId: 2149 + text: 'Members: Lava eel' + level: 53 + - itemId: 385 + text: 'Members: Shark' + level: 80 + - itemId: 397 + text: 'Members: Sea turtle' + level: 82 + - itemId: 391 + text: 'Members: Manta ray' + level: 90 + - name: Bread + lines: + - itemId: 2309 + text: Bread + level: 1 + - itemId: 1865 + text: 'Members: Pitta bread' + level: 58 + - name: Pies + lines: + - itemId: 2325 + text: Redberry pie + level: 10 + - itemId: 2327 + text: Meat pie + level: 20 + - itemId: 7170 + text: 'Members: Mud pie' + level: 29 + - itemId: 2323 + text: Apple pie + level: 30 + - itemId: 7178 + text: 'Members: Garden pie' + level: 34 + - itemId: 7188 + text: 'Members: Fish pie' + level: 47 + - itemId: 7198 + text: 'Members: Admiral pie' + level: 70 + - itemId: 7208 + text: 'Members: Wild pie' + level: 85 + - itemId: 7218 + text: 'Members: Summer pie' + level: 95 + - name: Stews + lines: + - itemId: 2003 + text: Stew + level: 25 + - itemId: 4016 + text: Banana stew + level: 25 + - itemId: 7479 + text: Spicy stew + level: 25 + - name: Pizzas + lines: + - itemId: 2289 + text: Plain pizza + level: 35 + - itemId: 2293 + text: Meat pizza + level: 45 + - itemId: 2297 + text: Anchovy pizza + level: 55 + - itemId: 2301 + text: 'Members: Pineapple pizza' + level: 65 + - name: Cakes + lines: + - itemId: 1891 + text: Cake + level: 40 + - itemId: 1897 + text: Chocolate cake + level: 50 + - name: Wine + lines: + - itemId: 1993 + text: Wine + level: 35 + - itemId: 245 + text: 'Members: Wine of Zamorak' + level: 65 + - name: Hot drinks + lines: + - itemId: 4239 + text: Nettle tea + level: 20 + - name: Brewing + lines: + - itemId: 5763 + text: Cider (4 Apple mush) + level: 14 + - itemId: 1913 + text: Dwarven Stout (4 Hammerstone hops) + level: 19 + - itemId: 1905 + text: Asgarnian Ale (4 Asgarnian hops) + level: 24 + - itemId: 7746 + text: Greenman's Ale (4 Harralander leaves) + level: 29 + - itemId: 1907 + text: Wizard's Mind Bomb (4 Yinillian hops) + level: 34 + - itemId: 1911 + text: Dragon Bitter (4 Krandorian hops) + level: 39 + - itemId: 2955 + text: Moonlight Mead (4 Bittercap mushrooms) + level: 44 + - itemId: 5751 + text: Axeman's Folly (1 Oak root) + level: 49 + - itemId: 5755 + text: Chef's Delight (4 Portions of chocolate dust) + level: 54 + - itemId: 5759 + text: Slayer's Respite (4 Wildblood hops) + level: 54 + - name: Vegetable + lines: + - itemId: 6701 + text: Baked potato + level: 7 + - itemId: 7072 + text: Spicy sauce (topping ingredient) + level: 9 + - itemId: 7078 + text: Scrambled egg (topping ingredient) + level: 13 + - itemId: 7064 + text: Scrambled egg and tomato (topping) + level: 23 + - itemId: 7088 + text: Sweetcorn + level: 28 + - itemId: 6703 + text: Baked potato with butter + level: 39 + - itemId: 7084 + text: Fried onion (topping ingredient) + level: 42 + - itemId: 7082 + text: Fried mushroom (topping ingredient) + level: 46 + - itemId: 6705 + text: Baked potato with butter and cheese + level: 47 + - itemId: 7056 + text: Baked potato with egg and tomato + level: 51 + - itemId: 7066 + text: Fried mushroom and onion (topping) + level: 57 + - itemId: 7058 + text: Baked potato with mushroom and onion + level: 64 + - itemId: 7068 + text: Tuna and sweetcorn (topping) + level: 67 + - itemId: 7060 + text: Baked potato with tuna and sweetcorn + level: 68 + - name: Diary + lines: + - itemId: 6697 + text: Butter + level: 38 + - itemId: 1985 + text: Cheese + level: 48 + +- id: 132 + name: Firemaking + members: false + subGuides: + - name: Burning + lines: + - itemId: 1511 + text: Normal logs + level: 1 + - itemId: 2862 + text: 'Members: Achey logs' + level: 1 + - itemId: 3438 + text: 'Members: Pyre logs' + level: 5 + - itemId: 1521 + text: Oak logs + level: 15 + - itemId: 3440 + text: 'Members: Oak pyre logs' + level: 20 + - itemId: 1519 + text: Willow logs + level: 30 + - itemId: 6333 + text: 'Members: Teak logs' + level: 35 + - itemId: 3442 + text: 'Members: Willow pyre logs' + level: 35 + - itemId: 6211 + text: 'Members: Teak pyre logs' + level: 40 + - itemId: 1517 + text: Maple logs + level: 45 + - itemId: 6332 + text: 'Members: Mahogany logs' + level: 50 + - itemId: 3444 + text: 'Members: Maple pyre logs' + level: 50 + - itemId: 6213 + text: 'Members: Mahogany pyre logs' + level: 55 + - itemId: 1515 + text: Yew logs + level: 60 + - itemId: 3446 + text: 'Members: Yew pyre logs' + level: 65 + - itemId: 1513 + text: 'Members: Magic logs' + level: 75 + - itemId: 3448 + text: 'Members: Magic pyre logs' + level: 80 + - name: Barbarian + lines: + - itemId: 3438 + text: Normal Pyre Ships (with 11 Crafting) + level: 11 + - itemId: 1511 + text: Normal Logs + level: 21 + - itemId: 2862 + text: Achey Logs + level: 21 + - itemId: 3440 + text: Oak Pyre Ships (with 25 Crafting) + level: 25 + - itemId: 1521 + text: Oak Logs + level: 35 + - itemId: 3442 + text: Willow Pyre Ships (with 40 Crafting) + level: 40 + - itemId: 6211 + text: Teak Pyre Ships (with 45 Crafting) + level: 45 + - itemId: 1519 + text: Willow Logs + level: 50 + - itemId: 6333 + text: Teak Logs + level: 55 + - itemId: 3444 + text: Maple Pyre Ships (with 55 Crafting) + level: 55 + - itemId: 6213 + text: Mahogany Pyre Ships (with 60 Crafting) + level: 60 + - itemId: 1517 + text: Maple Logs + level: 65 + - itemId: 3446 + text: Yew Pyre Ships (with 70 Crafting) + level: 70 + - itemId: 6332 + text: Mahogany Logs + level: 70 + - itemId: 1515 + text: Yew Logs + level: 80 + - itemId: 3448 + text: Magic Pyre Ships + level: 85 + - itemId: 1513 + text: Magic Logs + level: 95 + - name: Equipment + lines: + - itemId: 36 + text: Candle + level: 1 + - itemId: 4527 + text: Candle lanterns + level: 4 + - itemId: 4522 + text: Oil lamps + level: 12 + - itemId: 7225 + text: 'Members: Iron spits' + level: 20 + - itemId: 4535 + text: Oil lanterns + level: 26 + - itemId: 7051 + text: Harpie bug lanterns (not a light source) + level: 33 + - itemId: 4544 + text: Bullseye lanterns + level: 49 + - itemId: 4700 + text: Sapphire lanterns + level: 49 +- id: 120 + name: Mining + members: false + subGuides: + - name: Rocks + lines: + - itemId: 1436 + text: Rune essence (after RuneMysteries quest) + level: 1 + - itemId: 434 + text: Clay + level: 1 + - itemId: 436 + text: Copper ore + level: 1 + - itemId: 438 + text: Tin ore + level: 1 + - itemId: 668 + text: Blurite ore + level: 10 + - itemId: 3211 + text: 'Members: Limestone' + level: 10 + - itemId: 440 + text: Iron ore + level: 15 + - itemId: 442 + text: Silver ore + level: 20 + - itemId: 453 + text: Coal + level: 30 + - itemId: 444 + text: Gold + level: 40 + - itemId: 6983 + text: 'Members: Granite' + level: 45 + - itemId: 447 + text: Mithril ore + level: 55 + - itemId: 449 + text: Adamantite ore + level: 70 + - itemId: 1761 + text: 'Members: Soft clay' + level: 70 + - itemId: 451 + text: Runite ore + level: 85 + - name: Equipment + lines: + - itemId: 1265 + text: Bronze pickaxe + level: 1 + - itemId: 1267 + text: Iron pickaxe + level: 1 + - itemId: 1269 + text: Steel pickaxe + level: 6 + - itemId: 1273 + text: Mithril pickaxe + level: 21 + - itemId: 1271 + text: Adamant pickaxe + level: 31 + - itemId: 1275 + text: Rune pickaxe + level: 41 + - name: Areas + lines: + - itemId: 447 + text: Mining guild + level: 60 diff --git a/package-lock.json b/package-lock.json index 2816e3735..e9e1878b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,32 @@ { "name": "rune.js", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { "@babel/code-frame": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", - "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", "dev": true, "requires": { - "@babel/highlight": "^7.0.0" + "@babel/highlight": "^7.8.3" } }, + "@babel/helper-validator-identifier": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz", + "integrity": "sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw==", + "dev": true + }, "@babel/highlight": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", - "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", "dev": true, "requires": { + "@babel/helper-validator-identifier": "^7.9.0", "chalk": "^2.0.0", - "esutils": "^2.0.2", "js-tokens": "^4.0.0" } }, @@ -53,9 +59,9 @@ "integrity": "sha512-UFbtbGPjstz0eWHb+ga/GM3Z9EzqKXFWIbSOFURU0A/Gku0Bky4bCk9/h//K2Xr3IrCfjFNhMm4jyZ5dbCewGA==" }, "@hapi/hoek": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.5.0.tgz", - "integrity": "sha512-7XYT10CZfPsH7j9F1Jmg1+d0ezOux2oM2GfArAzLwWe4mE2Dr3hVjsAL6+TFY49RRJlCdJDMw3nJsLFroTc8Kw==" + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.5.1.tgz", + "integrity": "sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow==" }, "@hapi/joi": { "version": "16.1.8", @@ -98,12 +104,23 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, + "@runejs/byte-buffer": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@runejs/byte-buffer/-/byte-buffer-1.0.8.tgz", + "integrity": "sha512-tThSPAOlXbyukIEAvgv1nIzZsiP7MjfxIjMTmvnjMUmrlaVHmZowR3woXKtD7h/D1iEEQ333brw7RPi6x4bqjA==", + "requires": { + "@runejs/logger": "^1.0.0", + "typescript": "^3.7.2" + } + }, "@runejs/cache-parser": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@runejs/cache-parser/-/cache-parser-0.2.2.tgz", - "integrity": "sha512-Ifl/qqtr2neS7XWGybJumERm7+yhfNJwrzPh9skl80GIsvaRHL6xdOru/8W6zba5M03ZROhVswU0HozrYj9bzA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@runejs/cache-parser/-/cache-parser-0.6.3.tgz", + "integrity": "sha512-vIYz0PkPqKUbIDedpJ8JDFkHHcOSvzim/+97l0yMswcOZt5Tp+G2OA1zo8y9Nngs0OBw12bYUkhQ2d4ObqvlNQ==", "requires": { + "@runejs/byte-buffer": "^1.0.8", "@runejs/logger": "^1.0.0", + "pngjs": "^3.4.0", "seek-bzip": "^1.0.5", "typescript": "^3.7.2" } @@ -198,6 +215,12 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/lodash": { + "version": "4.14.149", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", + "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==", + "dev": true + }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -992,6 +1015,15 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "dev": true }, + "crc-32": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", + "integrity": "sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==", + "requires": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + } + }, "create-error-class": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz", @@ -1169,12 +1201,6 @@ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1195,6 +1221,11 @@ "strip-eof": "^1.0.0" } }, + "exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==" + }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -2091,8 +2122,7 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lowercase-keys": { "version": "1.0.1", @@ -2191,9 +2221,9 @@ } }, "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "dev": true }, "mixin-deep": { @@ -2218,18 +2248,18 @@ } }, "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.3.tgz", + "integrity": "sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg==", "dev": true, "requires": { - "minimist": "0.0.8" + "minimist": "^1.2.5" }, "dependencies": { "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "dev": true } } @@ -2534,6 +2564,11 @@ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "dev": true }, + "pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==" + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -2546,6 +2581,11 @@ "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", "dev": true }, + "printj": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==" + }, "proxy-addr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", @@ -3248,9 +3288,9 @@ "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" }, "tslint": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.20.1.tgz", - "integrity": "sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.0.tgz", + "integrity": "sha512-fXjYd/61vU6da04E505OZQGb2VCN2Mq3doeWcOIryuG+eqdmFUXTYVwdhnbEu2k46LNLgUYt9bI5icQze/j0bQ==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -3264,7 +3304,7 @@ "mkdirp": "^0.5.1", "resolve": "^1.3.2", "semver": "^5.3.0", - "tslib": "^1.8.0", + "tslib": "^1.10.0", "tsutils": "^2.29.0" }, "dependencies": { diff --git a/package.json b/package.json index 9eedaa372..4e0061efa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rune.js", - "version": "0.2.0", + "version": "0.4.0", "description": "", "main": "src/game-server.ts", "scripts": { @@ -24,14 +24,18 @@ "license": "GPL-3.0", "dependencies": { "@hapi/joi": "^16.1.8", - "@runejs/cache-parser": "^0.2.2", + "@runejs/cache-parser": "0.6.3", + "@runejs/byte-buffer": "1.0.8", "@runejs/logger": "^1.0.0", "bigi": "^1.4.2", "body-parser": "^1.19.0", + "crc-32": "^1.2.0", "express": "^4.17.1", "js-yaml": "^3.13.1", + "lodash": "^4.17.15", "quadtree-lib": "^1.0.9", "rxjs": "^6.5.4", + "source-map-support": "^0.5.16", "ts-node": "^8.4.1", "tslib": "^1.10.0", "typescript": "^3.7.2", @@ -44,6 +48,7 @@ "@types/express": "^4.17.2", "@types/hapi__joi": "^16.0.6", "@types/js-yaml": "^3.12.1", + "@types/lodash": "^4.14.149", "@types/node": "^12.12.6", "@types/uuid": "^3.4.6", "@types/yargs": "^13.0.4", @@ -51,6 +56,6 @@ "concurrently": "^5.1.0", "nodemon": "^2.0.2", "tsconfig-paths": "^3.9.0", - "tslint": "^5.20.1" + "tslint": "^6.1.0" } } diff --git a/src/data-dump.ts b/src/data-dump.ts new file mode 100644 index 000000000..6ac2e258a --- /dev/null +++ b/src/data-dump.ts @@ -0,0 +1,34 @@ +import { join } from 'path'; +import { writeFileSync } from 'fs'; +import { cache } from '@server/game-server'; +import { logger } from '@runejs/logger/dist/logger'; +import { ItemDefinition, NpcDefinition, Widget } from '@runejs/cache-parser'; + +function dump(fileName: string, definitions: Map): boolean { + const filePath = join('data/dump', fileName); + + const arr = []; + for(let i = 0; i < definitions.size; i++) { + arr.push(definitions.get(i)); + } + + try { + writeFileSync(filePath, JSON.stringify(arr, null, 4)); + return true; + } catch(error) { + logger.error(`Error dumping ${fileName}`); + return false; + } +} + +export function dumpNpcs(): boolean { + return dump('npcs.json', cache.npcDefinitions); +} + +export function dumpItems(): boolean { + return dump('items.json', cache.itemDefinitions); +} + +export function dumpWidgets(): boolean { + return dump('widgets.json', cache.widgets); +} diff --git a/src/error-handling.ts b/src/error-handling.ts new file mode 100644 index 000000000..7a7ccd045 --- /dev/null +++ b/src/error-handling.ts @@ -0,0 +1,42 @@ +import { logger } from '@runejs/logger/dist/logger'; + +/* + * Error handling! Feel free to add other types of errors or warnings here. :) + */ + +export class WidgetsClosedWarning extends Error { + constructor() { + super(); + this.name = 'WidgetsClosedWarning'; + this.message = 'The active widget was closed before the action could be completed.'; + } +} + +export class ActionsCancelledWarning extends Error { + constructor() { + super(); + this.name = 'ActionsCancelledWarning'; + this.message = 'Pending and active actions were cancelled before they could be completed.'; + } +} + +const warnings = [ + WidgetsClosedWarning, + ActionsCancelledWarning +]; + +export function initErrorHandling(): void { + process.on('unhandledRejection', (error, promise) => { + for(const t of warnings) { + if(error instanceof t) { + logger.warn(`Promise cancelled with warning: ${error.name}`); + return; + } + } + + logger.error(`Unhandled promise rejection from ${promise}, reason: ${error}`); + if(error.hasOwnProperty('stack')) { + logger.error((error as any).stack); + } + }); +} diff --git a/src/game-server.ts b/src/game-server.ts index 53d80454c..ae88638d1 100644 --- a/src/game-server.ts +++ b/src/game-server.ts @@ -1,22 +1,36 @@ import * as net from 'net'; import { watch } from 'chokidar'; +import * as CRC32 from 'crc-32'; -import { RsBuffer } from './net/rs-buffer'; import { World } from './world/world'; import { ClientConnection } from './net/client-connection'; import { logger } from '@runejs/logger'; -import { GameCache } from '@runejs/cache-parser'; -import { setNpcPlugins } from '@server/world/mob/player/action/npc-action'; -import { setObjectPlugins } from '@server/world/mob/player/action/object-action'; -import { loadPlugins } from '@server/plugins/plugin-loader'; -import { setItemOnItemPlugins } from '@server/world/mob/player/action/item-on-item-action'; -import { setButtonPlugins } from '@server/world/mob/player/action/button-action'; +import { Cache } from '@runejs/cache-parser'; import { parseServerConfig, ServerConfig } from '@server/world/config/server-config'; -import { ActionPlugin, ActionType } from '@server/plugins/plugin'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +import { loadPlugins } from '@server/plugins/plugin-loader'; +import { ActionPlugin, ActionType, sort } from '@server/plugins/plugin'; + +import { setNpcPlugins } from '@server/world/actor/player/action/npc-action'; +import { setObjectPlugins } from '@server/world/actor/player/action/object-action'; +import { setItemOnItemPlugins } from '@server/world/actor/player/action/item-on-item-action'; +import { setButtonPlugins } from '@server/world/actor/player/action/button-action'; +import { setCommandPlugins } from '@server/world/actor/player/action/input-command-action'; +import { setWidgetPlugins } from '@server/world/actor/player/action/widget-action'; +import { setItemPlugins } from '@server/world/actor/player/action/item-action'; +import { setWorldItemPlugins } from '@server/world/actor/player/action/world-item-action'; +import { setItemOnObjectPlugins } from '@server/world/actor/player/action/item-on-object-action'; +import { setItemOnNpcPlugins } from '@server/world/actor/player/action/item-on-npc-action'; +import { setPlayerInitPlugins } from '@server/world/actor/player/player'; +import { setNpcInitPlugins } from '@server/world/actor/npc/npc'; +import { setQuestPlugins } from '@server/world/config/quests'; + export let serverConfig: ServerConfig; -export let gameCache: GameCache; +export let cache: Cache; export let world: World; +export let crcTable: ByteBuffer; export async function injectPlugins(): Promise { const actionTypes: { [key: string]: ActionPlugin[] } = {}; @@ -30,10 +44,35 @@ export async function injectPlugins(): Promise { actionTypes[action.type].push(action); }); + Object.keys(actionTypes).forEach(key => actionTypes[key] = sort(actionTypes[key])); + + setQuestPlugins(actionTypes[ActionType.QUEST]); setButtonPlugins(actionTypes[ActionType.BUTTON]); setNpcPlugins(actionTypes[ActionType.NPC_ACTION]); setObjectPlugins(actionTypes[ActionType.OBJECT_ACTION]); - setItemOnItemPlugins(actionTypes[ActionType.ITEM_ON_ITEM]); + setItemOnObjectPlugins(actionTypes[ActionType.ITEM_ON_OBJECT_ACTION]); + setItemOnNpcPlugins(actionTypes[ActionType.ITEM_ON_NPC_ACTION]); + setItemOnItemPlugins(actionTypes[ActionType.ITEM_ON_ITEM_ACTION]); + setItemPlugins(actionTypes[ActionType.ITEM_ACTION]); + setWorldItemPlugins(actionTypes[ActionType.WORLD_ITEM_ACTION]); + setCommandPlugins(actionTypes[ActionType.COMMAND]); + setWidgetPlugins(actionTypes[ActionType.WIDGET_ACTION]); + setPlayerInitPlugins(actionTypes[ActionType.PLAYER_INIT]); + setNpcInitPlugins(actionTypes[ActionType.NPC_INIT]); +} + +function generateCrcTable(): void { + const index = cache.metaChannel; + const indexLength = index.length; + const buffer = new ByteBuffer(4048); + buffer.put(0, 'BYTE'); + buffer.put(indexLength, 'INT'); + for(let file = 0; file < (indexLength / 6); file++) { + const crcValue = CRC32.buf(cache.getRawFile(255, file)); + buffer.put(crcValue, 'INT'); + } + + crcTable = buffer; } export function runGameServer(): void { @@ -44,56 +83,59 @@ export function runGameServer(): void { return; } - gameCache = new GameCache('cache'); - world = new World(); - world.init(); - injectPlugins(); + cache = new Cache('cache', { + items: true, npcs: true, locationObjects: true, mapData: true, widgets: true + }); + generateCrcTable(); - if(process.argv.indexOf('-fakePlayers') !== -1) { - world.generateFakePlayers(); - } + world = new World(); + injectPlugins().then(() => { + world.init(); - process.on('unhandledRejection', (err, promise) => { - if(err === 'WIDGET_CLOSED') { - return; + if(process.argv.indexOf('-fakePlayers') !== -1) { + world.generateFakePlayers(); } - console.error('Unhandled rejection (promise: ', promise, ', reason: ', err, ').'); - throw err; - }); + net.createServer(socket => { + logger.info('Socket opened'); - net.createServer(socket => { - logger.info('Socket opened'); - // socket.setNoDelay(true); - let clientConnection = new ClientConnection(socket); + socket.setNoDelay(true); + socket.setKeepAlive(true); + socket.setTimeout(30000); - socket.on('data', data => { - if(clientConnection) { - clientConnection.parseIncomingData(new RsBuffer(data)); - } - }); + let clientConnection = new ClientConnection(socket); - socket.on('close', () => { - if(clientConnection) { - clientConnection.connectionDestroyed(); - clientConnection = null; - } - }); + socket.on('data', data => { + if(clientConnection) { + clientConnection.parseIncomingData(new ByteBuffer(data)); + } + }); - socket.on('error', error => { - socket.destroy(); - logger.error('Socket destroyed due to connection error.'); - }); - }).listen(serverConfig.port, serverConfig.host); + socket.on('close', () => { + if(clientConnection) { + clientConnection.connectionDestroyed(); + clientConnection = null; + } + }); - logger.info(`Game server listening on port ${serverConfig.port}.`); + socket.on('error', error => { + logger.error(error.message); + socket.destroy(); + logger.error('Socket destroyed due to connection error.'); + }); + }).listen(serverConfig.port, serverConfig.host); + + logger.info(`Game server listening on port ${serverConfig.port}.`); + }); const watcher = watch('dist/plugins/'); - watcher.on('ready', function() { - watcher.on('all', function() { - Object.keys(require.cache).forEach(function(id) { - if (/[\/\\]plugins[\/\\]/.test(id)) delete require.cache[id]; + watcher.on('ready', () => { + watcher.on('all', () => { + Object.keys(require.cache).forEach((id) => { + if(/[\/\\]plugins[\/\\]/.test(id)) { + delete require.cache[id]; + } }); - }) + }); }); } diff --git a/src/main.ts b/src/main.ts index b55d2af5d..479d572d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,10 @@ import { runGameServer } from './game-server'; import { runWebServer } from './web-server'; +import 'source-map-support/register'; +import { initErrorHandling } from '@server/error-handling'; +// import { dumpItems } from '@server/data-dump'; +initErrorHandling(); runGameServer(); runWebServer(); +// dumpItems(); diff --git a/src/net/client-connection.ts b/src/net/client-connection.ts index 45b443478..e217c750e 100644 --- a/src/net/client-connection.ts +++ b/src/net/client-connection.ts @@ -1,14 +1,18 @@ import { Socket } from 'net'; -import { Player } from '@server/world/mob/player/player'; +import { Player } from '@server/world/actor/player/player'; import { world } from '@server/game-server'; -import { RsBuffer } from './rs-buffer'; -import { ClientHandshakeParser } from './data-parser/client-handshake-parser'; +import { LoginHandshakeParser } from './data-parser/login-handshake-parser'; import { ClientLoginParser } from './data-parser/client-login-parser'; import { ClientPacketDataParser } from './data-parser/client-packet-data-parser'; import { DataParser } from './data-parser/data-parser'; +import { VersionHandshakeParser } from '@server/net/data-parser/version-handshake-parser'; +import { UpdateServerParser } from '@server/net/data-parser/update-server-parser'; +import { ByteBuffer } from '@runejs/byte-buffer'; enum ConnectionStage { - HANDSHAKE = 'HANDSHAKE', + VERSION_HANDSHAKE = 'VERSION_HANDSHAKE', + UPDATE_SERVER = 'UPDATE_SERVER', + LOGIN_HANDSHAKE = 'LOGIN_HANDSHAKE', LOGIN = 'LOGIN', LOGGED_IN = 'LOGGED_IN' } @@ -19,7 +23,7 @@ enum ConnectionStage { export class ClientConnection { public readonly socket: Socket; - private _connectionStage: ConnectionStage = ConnectionStage.HANDSHAKE; + private _connectionStage: ConnectionStage = null; private dataParser: DataParser; private _serverKey: bigint; private _clientKey1: bigint; @@ -28,14 +32,31 @@ export class ClientConnection { public constructor(socket: Socket) { this.socket = socket; - this.dataParser = new ClientHandshakeParser(this); + this.dataParser = null; } - public parseIncomingData(buffer?: RsBuffer): void { + public parseIncomingData(buffer?: ByteBuffer): void { try { - this.dataParser.parse(buffer); + if(!this.connectionStage) { + const packetId = buffer.get('BYTE', 'UNSIGNED'); + + if(packetId === 15) { + this.connectionStage = ConnectionStage.VERSION_HANDSHAKE; + this.dataParser = new VersionHandshakeParser(this); + } else if(packetId === 14) { + this.connectionStage = ConnectionStage.LOGIN_HANDSHAKE; + this.dataParser = new LoginHandshakeParser(this); + } + + this.dataParser.parse(buffer, packetId); + } else { + this.dataParser.parse(buffer); + } - if(this.connectionStage === ConnectionStage.HANDSHAKE) { + if(this.connectionStage === ConnectionStage.VERSION_HANDSHAKE) { + this.connectionStage = ConnectionStage.UPDATE_SERVER; + this.dataParser = new UpdateServerParser(this); + } else if(this.connectionStage === ConnectionStage.LOGIN_HANDSHAKE) { this.connectionStage = ConnectionStage.LOGIN; this.dataParser = new ClientLoginParser(this); } else if(this.connectionStage === ConnectionStage.LOGIN) { @@ -43,9 +64,9 @@ export class ClientConnection { this.dataParser = new ClientPacketDataParser(this); } } catch(err) { - this.socket.destroy(); console.error('Error decoding client data'); console.error(err); + this.socket.destroy(); } } diff --git a/src/net/data-parser/client-handshake-parser.ts b/src/net/data-parser/client-handshake-parser.ts deleted file mode 100644 index cc6ee11fd..000000000 --- a/src/net/data-parser/client-handshake-parser.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { RsBuffer } from '@server/net/rs-buffer'; -import { DataParser } from './data-parser'; - -/** - * Controls the initial client handshake with the server. - */ -export class ClientHandshakeParser extends DataParser { - - public parse(buffer?: RsBuffer): void { - if(!buffer) { - throw ('No data supplied for client handshake'); - } - - const handshakePacketId = buffer.readUnsignedByte(); - - if(handshakePacketId === 14) { - buffer.readUnsignedByte(); // Name hash - - const outputBuffer = RsBuffer.create(); - for(let i = 0; i < 8; i++) { - outputBuffer.writeByte(0); - } - - const serverKey = BigInt(1337); // TODO generate server_key - - outputBuffer.writeByte(0); // Initial server login response -> 0 for OK - outputBuffer.writeLongBE(serverKey); - this.clientConnection.socket.write(outputBuffer.getData()); - - this.clientConnection.serverKey = serverKey; - } else { - throw 'Invalid handshake packet id.'; - } - } -} diff --git a/src/net/data-parser/client-login-parser.ts b/src/net/data-parser/client-login-parser.ts index 3c674fc8c..22d2411d2 100644 --- a/src/net/data-parser/client-login-parser.ts +++ b/src/net/data-parser/client-login-parser.ts @@ -1,9 +1,28 @@ import BigInteger from 'bigi'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { Player } from '@server/world/mob/player/player'; +import { Player } from '@server/world/actor/player/player'; import { Isaac } from '@server/net/isaac'; import { serverConfig, world } from '@server/game-server'; import { DataParser } from './data-parser'; +import { logger } from '@runejs/logger/dist/logger'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +const VALID_CHARS = ['_', 'a', 'b', 'c', 'd', + 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', + 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', '!', '@', '#', '$', '%', '^', '&', + '*', '(', ')', '-', '+', '=', ':', ';', '.', '>', '<', ',', '"', + '[', ']', '|', '?', '/', '`']; + +function longToName(nameLong: BigInt): string { + let ac: string = ''; + while(nameLong !== BigInt(0)) { + const l1 = nameLong; + nameLong = BigInt(nameLong) / BigInt(37); + ac += VALID_CHARS[parseInt(l1.toString()) - parseInt(nameLong.toString()) * 37]; + } + + return ac.split('').reverse().join(''); +} /** * Parses the login packet from the game client. @@ -13,70 +32,63 @@ export class ClientLoginParser extends DataParser { private readonly rsaModulus = BigInteger(serverConfig.rsaMod); private readonly rsaExponent = BigInteger(serverConfig.rsaExp); - public parse(buffer?: RsBuffer): void { + public parse(buffer?: ByteBuffer): void { if(!buffer) { - throw ('No data supplied for login'); + throw new Error('No data supplied for login'); } - const loginType = buffer.readUnsignedByte(); + const loginType = buffer.get('BYTE', 'UNSIGNED'); if(loginType !== 16 && loginType !== 18) { - throw ('Invalid login type ' + loginType); + throw new Error('Invalid login type ' + loginType); } - let loginEncryptedSize = buffer.readUnsignedByte() - (36 + 1 + 1 + 2); + let loginEncryptedSize = buffer.get('BYTE', 'UNSIGNED') - (36 + 1 + 1 + 2); if(loginEncryptedSize <= 0) { - throw ('Invalid login packet length ' + loginEncryptedSize); - } - - const packetId = buffer.readUnsignedByte(); - - if(packetId !== 255) { - throw ('Invalid login packet id ' + packetId); + throw new Error('Invalid login packet length ' + loginEncryptedSize); } - const gameVersion = buffer.readUnsignedShortBE(); + const gameVersion = buffer.get('INT'); - if(gameVersion !== 377) { - throw ('Invalid game version ' + gameVersion); + if(gameVersion !== 435) { + throw new Error('Invalid game version ' + gameVersion); } - const isLowDetail: boolean = buffer.readByte() === 1; + const isLowDetail: boolean = buffer.get('BYTE') === 1; - for(let i = 0; i < 9; i++) { - buffer.readIntBE(); // Cache indices + for(let i = 0; i < 13; i++) { + buffer.get('INT'); // Cache indices } loginEncryptedSize--; - const reportedSize = buffer.readUnsignedByte(); + const rsaBytes = buffer.get('BYTE', 'UNSIGNED'); - if(loginEncryptedSize !== reportedSize) { - throw (`Packet size mismatch - ${loginEncryptedSize} vs ${reportedSize}`); - } - - const encryptedBytes: Buffer = Buffer.alloc(loginEncryptedSize); - buffer.getBuffer().copy(encryptedBytes, 0, buffer.getReaderIndex()); - const decrypted: RsBuffer = new RsBuffer(BigInteger.fromBuffer(encryptedBytes).modPow(this.rsaExponent, this.rsaModulus).toBuffer()); + const encryptedBytes: Buffer = Buffer.alloc(rsaBytes); + buffer.copy(encryptedBytes, 0, buffer.readerIndex); + const decrypted = new ByteBuffer(BigInteger.fromBuffer(encryptedBytes).modPow(this.rsaExponent, this.rsaModulus).toBuffer()); - const blockId = decrypted.readByte(); + const blockId = decrypted.get('BYTE'); if(blockId !== 10) { - throw ('Invalid block id ' + blockId); + throw new Error('Invalid block id ' + blockId); } - const clientKey1 = decrypted.readIntBE(); - const clientKey2 = decrypted.readIntBE(); - const incomingServerKey = decrypted.readLongBE(); + const clientKey1 = decrypted.get('INT'); + const clientKey2 = decrypted.get('INT'); + const incomingServerKey = BigInt(decrypted.get('LONG')); if(this.clientConnection.serverKey !== incomingServerKey) { - throw (`Server key mismatch - ${this.clientConnection.serverKey} != ${incomingServerKey}`); + throw new Error(`Server key mismatch - ${this.clientConnection.serverKey} != ${incomingServerKey}`); } - const clientUuid = decrypted.readIntBE(); - const username = decrypted.readString(); - const password = decrypted.readString(); + const clientUuid = decrypted.get('INT'); + const usernameLong = BigInt(decrypted.get('LONG')); + const username = longToName(usernameLong); + const password = decrypted.getString(); + + logger.info(`Login request: ${username}/${password}`); const sessionKey: number[] = [ Number(clientKey1), Number(clientKey2), Number(this.clientConnection.serverKey >> BigInt(32)), Number(this.clientConnection.serverKey) @@ -92,14 +104,18 @@ export class ClientLoginParser extends DataParser { const player = new Player(this.clientConnection.socket, inCipher, outCipher, clientUuid, username, password, isLowDetail); - const outputBuffer = RsBuffer.create(); - outputBuffer.writeByte(2); // login response code - outputBuffer.writeByte(player.rights.valueOf()); - outputBuffer.writeByte(0); // ??? - this.clientConnection.socket.write(outputBuffer.getData()); - world.registerPlayer(player); + const outputBuffer = new ByteBuffer(6); + outputBuffer.put(2, 'BYTE'); // login response code + outputBuffer.put(player.rights.valueOf(), 'BYTE'); + outputBuffer.put(0, 'BYTE'); // ??? + outputBuffer.put(player.worldIndex + 1, 'SHORT'); + outputBuffer.put(0, 'BYTE'); // ??? + this.clientConnection.socket.write(outputBuffer); + + player.init(); + this.clientConnection.clientKey1 = BigInt(clientKey1); this.clientConnection.clientKey2 = BigInt(clientKey2); this.clientConnection.player = player; diff --git a/src/net/data-parser/client-packet-data-parser.ts b/src/net/data-parser/client-packet-data-parser.ts index e3d9afe6a..6d5cf7251 100644 --- a/src/net/data-parser/client-packet-data-parser.ts +++ b/src/net/data-parser/client-packet-data-parser.ts @@ -1,7 +1,7 @@ -import { RsBuffer } from '@server/net/rs-buffer'; import { incomingPacketSizes } from '@server/net/incoming-packet-sizes'; -import { handlePacket } from '@server/world/mob/player/packet/incoming-packet-directory'; +import { handlePacket } from '@server/net/incoming-packet-directory'; import { DataParser } from './data-parser'; +import { ByteBuffer } from '@runejs/byte-buffer'; /** * Parses incoming packet data from the game client once the user is fully authenticated. @@ -10,16 +10,16 @@ export class ClientPacketDataParser extends DataParser { private activePacketId: number = null; private activePacketSize: number = null; - private activeBuffer: RsBuffer; + private activeBuffer: ByteBuffer; - public parse(buffer?: RsBuffer): void { + public parse(buffer?: ByteBuffer): void { if(!this.activeBuffer) { this.activeBuffer = buffer; } else if(buffer) { - const newBuffer = new RsBuffer(this.activeBuffer.getUnreadData()); - const activeLength = newBuffer.getBuffer().length; - newBuffer.ensureCapacity(activeLength + buffer.getBuffer().length); - buffer.getBuffer().copy(newBuffer.getBuffer(), activeLength, 0); + const readable = this.activeBuffer.readable; + const newBuffer = new ByteBuffer(readable + buffer.length); + this.activeBuffer.copy(newBuffer, 0, this.activeBuffer.readerIndex); + buffer.copy(newBuffer, readable, 0); this.activeBuffer = newBuffer; } @@ -34,39 +34,56 @@ export class ClientPacketDataParser extends DataParser { const inCipher = this.clientConnection.player.inCipher; if(this.activePacketId === -1) { - if(this.activeBuffer.getReadable() < 1) { + if(this.activeBuffer.readable < 1) { return; } - this.activePacketId = this.activeBuffer.readByte() & 0xff; + this.activePacketId = this.activeBuffer.get('BYTE', 'UNSIGNED'); this.activePacketId = (this.activePacketId - inCipher.rand()) & 0xff; this.activePacketSize = incomingPacketSizes[this.activePacketId]; } + // Packet will provide the size if(this.activePacketSize === -1) { - if(this.activeBuffer.getReadable() < 1) { + if(this.activeBuffer.readable < 1) { return; } - this.activePacketSize = this.activeBuffer.readByte() & 0xff; + this.activePacketSize = this.activeBuffer.get('BYTE', 'UNSIGNED'); } - if(this.activeBuffer.getReadable() < this.activePacketSize) { - console.error('Not enough readable data for packet ' + this.activePacketId + ' with size ' + this.activePacketSize + ', but only ' + - this.activeBuffer.getReadable() + ' data is left of ' + this.activeBuffer.getBuffer().length); + // Packet has no set size + let clearBuffer = false; + if(this.activePacketSize === -3) { + if(this.activeBuffer.readable < 1) { + return; + } + + this.activePacketSize = this.activeBuffer.readable; + clearBuffer = true; + } + + if(this.activeBuffer.readable < this.activePacketSize) { return; } + // read packet data + let packetData = null; if(this.activePacketSize !== 0) { - // read packet data - const packetData = this.activeBuffer.readBytes(this.activePacketSize); - handlePacket(this.clientConnection.player, this.activePacketId, this.activePacketSize, packetData); + packetData = new ByteBuffer(this.activePacketSize); + this.activeBuffer.copy(packetData, 0, this.activeBuffer.readerIndex, this.activeBuffer.readerIndex + this.activePacketSize); + this.activeBuffer.readerIndex += this.activePacketSize; + } + handlePacket(this.clientConnection.player, this.activePacketId, this.activePacketSize, packetData); + + if(clearBuffer) { + this.activeBuffer = null; } this.activePacketId = null; this.activePacketSize = null; - if(this.activeBuffer.getReadable() > 0) { + if(this.activeBuffer !== null && this.activeBuffer.readable > 0) { this.parse(); } } diff --git a/src/net/data-parser/data-parser.ts b/src/net/data-parser/data-parser.ts index d0a1dda66..b84128d6a 100644 --- a/src/net/data-parser/data-parser.ts +++ b/src/net/data-parser/data-parser.ts @@ -1,11 +1,11 @@ import { ClientConnection } from '@server/net/client-connection'; -import { RsBuffer } from '@server/net/rs-buffer'; +import { ByteBuffer } from '@runejs/byte-buffer'; export abstract class DataParser { public constructor(protected readonly clientConnection: ClientConnection) { } - public abstract parse(buffer?: RsBuffer): void; + public abstract parse(buffer?: ByteBuffer, packetId?: number): void | boolean; } diff --git a/src/net/data-parser/login-handshake-parser.ts b/src/net/data-parser/login-handshake-parser.ts new file mode 100644 index 000000000..81c6154a7 --- /dev/null +++ b/src/net/data-parser/login-handshake-parser.ts @@ -0,0 +1,29 @@ +import { DataParser } from './data-parser'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +/** + * Controls the initial login handshake with the server. + */ +export class LoginHandshakeParser extends DataParser { + + public parse(buffer: ByteBuffer, packetId: number): void { + if(!buffer) { + throw new Error('No data supplied for login handshake'); + } + + if(packetId === 14) { + buffer.get('BYTE', 'UNSIGNED'); // Name hash + + const serverKey = BigInt(13371337); // TODO generate server_key + + const outputBuffer = new ByteBuffer(9); + outputBuffer.put(0, 'BYTE'); // Initial server login response -> 0 for OK + outputBuffer.put(serverKey, 'LONG'); + this.clientConnection.socket.write(outputBuffer); + + this.clientConnection.serverKey = serverKey; + } else { + throw new Error('Invalid login handshake packet id.'); + } + } +} diff --git a/src/net/data-parser/update-server-parser.ts b/src/net/data-parser/update-server-parser.ts new file mode 100644 index 000000000..61e5e17b2 --- /dev/null +++ b/src/net/data-parser/update-server-parser.ts @@ -0,0 +1,81 @@ +import { ByteBuffer } from '@runejs/byte-buffer'; +import { DataParser } from './data-parser'; +import { crcTable, cache } from '@server/game-server'; + +/** + * Handles the cache update server. + */ +export class UpdateServerParser extends DataParser { + + private files: { file: number, index: number }[] = []; + + public parse(buffer?: ByteBuffer): void { + if(!buffer) { + return; + } + + while(buffer.readable >= 4) { + const type = buffer.get('BYTE', 'UNSIGNED'); + const index = buffer.get('BYTE', 'UNSIGNED'); + const file = buffer.get('SHORT', 'UNSIGNED'); + + switch(type) { + case 0: // queue + this.files.push({ index, file }); + break; + case 1: // immediate + this.clientConnection.socket.write(this.generateFile(index, file)); + break; + case 2: + case 3: // clear queue + this.files = []; + break; + case 4: // error + break; + } + + while(this.files.length > 0) { + const info = this.files.shift(); + this.clientConnection.socket.write(this.generateFile(info.index, info.file)); + } + } + } + + private generateFile(index: number, file: number): Buffer { + let cacheFile: ByteBuffer; + + if(index === 255 && file === 255) { + cacheFile = new ByteBuffer(crcTable.length); + crcTable.copy(cacheFile, 0, 0); + } else { + cacheFile = cache.getRawFile(index, file); + } + + if(!cacheFile || cacheFile.length === 0) { + throw new Error(`Cache file not found; file(${file}) with index(${index})`); + } + + const buffer = new ByteBuffer((cacheFile.length - 2) + ((cacheFile.length - 2) / 511) + 8); + buffer.put(index, 'BYTE'); + buffer.put(file, 'SHORT'); + + let length: number = ((cacheFile.at(1, 'UNSIGNED') << 24) + (cacheFile.at(2, 'UNSIGNED') << 16) + + (cacheFile.at(3, 'UNSIGNED') << 8) + cacheFile.at(4, 'UNSIGNED')) + 9; + if(cacheFile.at(0) === 0) { + length -= 4; + } + + let c = 3; + for(let i = 0; i < length; i++) { + if(c === 512) { + buffer.put(255, 'BYTE'); + c = 1; + } + + buffer.put(cacheFile.at(i), 'BYTE'); + c++; + } + + return Buffer.from(buffer.flipWriter()); + } +} diff --git a/src/net/data-parser/version-handshake-parser.ts b/src/net/data-parser/version-handshake-parser.ts new file mode 100644 index 000000000..39b0308cb --- /dev/null +++ b/src/net/data-parser/version-handshake-parser.ts @@ -0,0 +1,24 @@ +import { DataParser } from './data-parser'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +/** + * Controls the version handshake with the server. + */ +export class VersionHandshakeParser extends DataParser { + + public parse(buffer: ByteBuffer, packetId: number): void { + if(!buffer) { + throw new Error('No data supplied for version handshake'); + } + + if(packetId === 15) { + const gameVersion = buffer.get('INT'); + + const outputBuffer = new ByteBuffer(1); + outputBuffer.put(gameVersion === 435 ? 0 : 6, 'BYTE'); + this.clientConnection.socket.write(outputBuffer); + } else { + throw new Error('Invalid version handshake packet id.'); + } + } +} diff --git a/src/net/incoming-packet-directory.ts b/src/net/incoming-packet-directory.ts new file mode 100644 index 000000000..d0301bc02 --- /dev/null +++ b/src/net/incoming-packet-directory.ts @@ -0,0 +1,80 @@ +import { Player } from '../world/actor/player/player'; +import { logger } from '@runejs/logger'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +import { incomingPacket } from './incoming-packet'; +import { characterDesignPacket } from './incoming-packets/character-design-packet'; +import { itemEquipPacket } from './incoming-packets/item-equip-packet'; +import { buttonClickPacket } from './incoming-packets/button-click-packet'; +import { walkPacket } from './incoming-packets/walk-packet'; +import { commandPacket } from './incoming-packets/command-packet'; +import { itemSwapPacket } from './incoming-packets/item-swap-packet'; +import { widgetInteractionPacket } from '@server/net/incoming-packets/widget-interaction-packet'; +import { npcInteractionPacket } from '@server/net/incoming-packets/npc-interaction-packet'; +import { objectInteractionPacket } from '@server/net/incoming-packets/object-interaction-packet'; +import { chatPacket } from '@server/net/incoming-packets/chat-packet'; +import { dropItemPacket } from '@server/net/incoming-packets/drop-item-packet'; +import { itemOnItemPacket } from '@server/net/incoming-packets/item-on-item-packet'; +import { widgetsClosedPacket } from '@server/net/incoming-packets/widgets-closed-packet'; +import { pickupItemPacket } from '@server/net/incoming-packets/pickup-item-packet'; +import { itemInteractionPacket } from '@server/net/incoming-packets/item-interaction-packet'; +import { itemOnObjectPacket } from '@server/net/incoming-packets/item-on-object-packet'; +import { numberInputPacket } from '@server/net/incoming-packets/number-input-packet'; +import { itemOnNpcPacket } from '@server/net/incoming-packets/item-on-npc-packet'; + +const ignore = [ 234, 160, 216, 13, 58 /* camera move */ ]; + +const packets: { [key: number]: incomingPacket } = { + 75: chatPacket, + 248: commandPacket, + 246: commandPacket, + + 73: walkPacket, + 236: walkPacket, + 89: walkPacket, + + 64: buttonClickPacket, + 132: widgetInteractionPacket, + 176: widgetsClosedPacket, + 231: characterDesignPacket, + 238: numberInputPacket, + //86: stringInputPacket, @TODO + + 83: itemSwapPacket, + 40: itemOnItemPacket, + 24: itemOnObjectPacket, + 208: itemOnNpcPacket, + 102: itemEquipPacket, + 38: itemInteractionPacket, + 98: itemInteractionPacket, + 228: itemInteractionPacket, + 26: itemInteractionPacket, + 147: itemInteractionPacket, + 29: dropItemPacket, + 85: pickupItemPacket, + + 63: npcInteractionPacket, + 116: npcInteractionPacket, + + 30: objectInteractionPacket, + 164: objectInteractionPacket, + 183: objectInteractionPacket, +}; + +export function handlePacket(player: Player, packetId: number, packetSize: number, buffer: Buffer): void { + if(ignore.indexOf(packetId) !== -1) { + return; + } + + const packetFunction = packets[packetId]; + + if(!packetFunction) { + logger.info(`Unknown packet ${packetId} with size ${packetSize} received.`); + return; + } + + new Promise(resolve => { + packetFunction(player, packetId, packetSize, new ByteBuffer(buffer)); + resolve(); + }).catch(error => logger.error(`Error handling inbound packet: ${error}`)); +} diff --git a/src/net/incoming-packet-sizes.ts b/src/net/incoming-packet-sizes.ts index 081e5ae2c..270537d41 100644 --- a/src/net/incoming-packet-sizes.ts +++ b/src/net/incoming-packet-sizes.ts @@ -1,86 +1,28 @@ -export const incomingPacketSizes: number[] = new Array(256); - -for(let i = 0; i < incomingPacketSizes.length; i++) { - incomingPacketSizes[i] = 0; -} - -incomingPacketSizes[1] = 12; -incomingPacketSizes[3] = 6; -incomingPacketSizes[4] = 6; -incomingPacketSizes[6] = 0; -incomingPacketSizes[8] = 2; -incomingPacketSizes[13] = 2; -incomingPacketSizes[19] = 4; -incomingPacketSizes[22] = 2; -incomingPacketSizes[24] = 6; -incomingPacketSizes[28] = -1; -incomingPacketSizes[31] = 4; -incomingPacketSizes[36] = 8; -incomingPacketSizes[40] = 0; -incomingPacketSizes[42] = 2; -incomingPacketSizes[45] = 2; -incomingPacketSizes[49] = -1; -incomingPacketSizes[50] = 6; -incomingPacketSizes[54] = 6; -incomingPacketSizes[55] = 6; -incomingPacketSizes[56] = -1; -incomingPacketSizes[57] = 8; -incomingPacketSizes[67] = 2; -incomingPacketSizes[71] = 6; -incomingPacketSizes[75] = 4; -incomingPacketSizes[77] = 6; -incomingPacketSizes[78] = 4; -incomingPacketSizes[79] = 2; -incomingPacketSizes[80] = 2; -incomingPacketSizes[83] = 8; -incomingPacketSizes[91] = 6; -incomingPacketSizes[95] = 4; -incomingPacketSizes[100] = 6; -incomingPacketSizes[104] = 4; -incomingPacketSizes[110] = 0; -incomingPacketSizes[112] = 2; -incomingPacketSizes[116] = 2; -incomingPacketSizes[119] = 1; -incomingPacketSizes[120] = 8; -incomingPacketSizes[123] = 7; -incomingPacketSizes[126] = 1; -incomingPacketSizes[136] = 6; -incomingPacketSizes[140] = 4; -incomingPacketSizes[141] = 8; -incomingPacketSizes[143] = 8; -incomingPacketSizes[152] = 12; -incomingPacketSizes[157] = 4; -incomingPacketSizes[158] = 6; -incomingPacketSizes[160] = 8; -incomingPacketSizes[161] = 6; -incomingPacketSizes[163] = 13; -incomingPacketSizes[165] = 1; -incomingPacketSizes[168] = 0; -incomingPacketSizes[171] = -1; -incomingPacketSizes[173] = 3; -incomingPacketSizes[176] = 3; -incomingPacketSizes[177] = 6; -incomingPacketSizes[181] = 6; -incomingPacketSizes[184] = 10; -incomingPacketSizes[187] = 1; -incomingPacketSizes[194] = 2; -incomingPacketSizes[197] = 4; -incomingPacketSizes[202] = 0; -incomingPacketSizes[203] = 6; -incomingPacketSizes[206] = 8; -incomingPacketSizes[210] = 8; -incomingPacketSizes[211] = 12; -incomingPacketSizes[213] = -1; -incomingPacketSizes[217] = 8; -incomingPacketSizes[222] = 3; -incomingPacketSizes[226] = 2; -incomingPacketSizes[227] = -1; -incomingPacketSizes[228] = 6; -incomingPacketSizes[230] = 6; -incomingPacketSizes[231] = 6; -incomingPacketSizes[233] = 2; -incomingPacketSizes[241] = 6; -incomingPacketSizes[244] = -1; -incomingPacketSizes[245] = 2; -incomingPacketSizes[247] = -1; -incomingPacketSizes[248] = 0; +export const incomingPacketSizes: number[] = [ + -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, //0 + -3, -3, -3, 0, -3, -3, -3, -3, -3, -3, //10 + -3, -3, -3, -3, -3, -3, 8, -3, -3, 8, //20 + 6, -3, -3, -3, -3, -3, -3, -3, 8, -3, //30 + 16, -3, -3, -3, -3, -3, -3, -3, -3, -3, //40 + -3, -3, -3, -3, -3, -3, -3, -3, 4, -3, //50 + -3, -3, -3, 2, 4, 6, -3, -3, -3, -3, //60 + -3, -3, -3, -1, -3, -3, -3, -3, -3, -3, //70 + -3, -3, -3, 9, -3, 6, 8, -3, -3, -1, //80 + -3, -3, -3, -3, -3, -3, -3, -3, 8, -3, //90 + -3, -3, 8, -3, -3, -3, -3, -3, -3, -3, //100 + -3, -3, -3, -3, -3, -3, 2, -3, -3, -3, //110 + -3, 4, -3, -3, -3, -3, -3, -3, -3, -3, //120 + -3, -3, 6, -3, -3, -3, -3, -3, -3, -3, //130 + -3, -3, -3, -3, -3, -3, -3, 8, -3, -3, //140 + -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, //150 + 1, -3, -3, -3, 6, -3, -3, -3, -3, -3, //160 + -3, -3, -3, -3, -3, -3, 0, -3, 0, -3, //170 + -3, -3, -3, 6, -3, -3, -3, -3, -3, -3, //180 + -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, //190 + -3, -3, -3, -3, -3, -3, -3, -3, 10, -3, //200 + -3, -3, -3, -3, -3, -3, 0, -3, -3, -3, //210 + -3, -3, -3, -3, -3, -3, -3, -3, 8, -3, //220 + -3, 13, -3, -3, 4, -3, -1, -3, 4, -3, //230 + -3, -3, -3, -3, -3, -1, -3, -3, -1, -3, //240 + -3, -3, -3, -3, -3, -3, -3 //250 +]; diff --git a/src/net/incoming-packet.ts b/src/net/incoming-packet.ts new file mode 100644 index 000000000..929635713 --- /dev/null +++ b/src/net/incoming-packet.ts @@ -0,0 +1,4 @@ +import { Player } from '../world/actor/player/player'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export type incomingPacket = (player: Player, packetId: number, packetSize: number, buffer: ByteBuffer) => void; diff --git a/src/net/incoming-packets/button-click-packet.ts b/src/net/incoming-packets/button-click-packet.ts new file mode 100644 index 000000000..ce9393862 --- /dev/null +++ b/src/net/incoming-packets/button-click-packet.ts @@ -0,0 +1,17 @@ +import { incomingPacket } from '../incoming-packet'; +import { ByteBuffer } from '@runejs/byte-buffer'; +import { Player } from '../../world/actor/player/player'; +import { buttonAction } from '@server/world/actor/player/action/button-action'; + +const ignoreButtons: string[] = [ + '269:99' // character design accept button +]; + +export const buttonClickPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const widgetId = packet.get('SHORT'); + const buttonId = packet.get('SHORT'); + + if(ignoreButtons.indexOf(`${widgetId}:${buttonId}`) === -1) { + buttonAction(player, widgetId, buttonId); + } +}; diff --git a/src/net/incoming-packets/camera-turn-packet.ts b/src/net/incoming-packets/camera-turn-packet.ts new file mode 100644 index 000000000..e766590cf --- /dev/null +++ b/src/net/incoming-packets/camera-turn-packet.ts @@ -0,0 +1,7 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const cameraTurnPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + // Do nothing +}; diff --git a/src/world/mob/player/packet/impl/character-design-packet.ts b/src/net/incoming-packets/character-design-packet.ts similarity index 68% rename from src/world/mob/player/packet/impl/character-design-packet.ts rename to src/net/incoming-packets/character-design-packet.ts index 872490688..e368a96ce 100644 --- a/src/world/mob/player/packet/impl/character-design-packet.ts +++ b/src/net/incoming-packets/character-design-packet.ts @@ -1,25 +1,25 @@ import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { widgetIds } from '../../widget'; +import { Player } from '../../world/actor/player/player'; +import { widgets } from '../../world/config/widget'; +import { ByteBuffer } from '@runejs/byte-buffer'; -export const characterDesignPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - if(!player.activeWidget || player.activeWidget.widgetId !== widgetIds.characterDesign) { +export const characterDesignPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + if(!player.activeWidget || player.activeWidget.widgetId !== widgets.characterDesign) { return; } // @TODO verify validity of selections - const gender: number = packet.readByte(); + const gender: number = packet.get(); const models: number[] = new Array(7); const colors: number[] = new Array(5); for(let i = 0; i < models.length; i++) { - models[i] = packet.readByte(); + models[i] = packet.get(); } for(let i = 0; i < colors.length; i++) { - colors[i] = packet.readByte(); + colors[i] = packet.get(); } player.appearance = { @@ -39,5 +39,5 @@ export const characterDesignPacket: incomingPacket = (player: Player, packetId: }; player.updateFlags.appearanceUpdateRequired = true; - player.closeActiveWidget(); + player.closeActiveWidgets(); }; diff --git a/src/net/incoming-packets/chat-packet.ts b/src/net/incoming-packets/chat-packet.ts new file mode 100644 index 000000000..62668df14 --- /dev/null +++ b/src/net/incoming-packets/chat-packet.ts @@ -0,0 +1,11 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const chatPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + packet.get(); + const color: number = packet.get(); + const effects: number = packet.get(); + const data: Buffer = Buffer.from(packet.getSlice(packet.readerIndex, packet.length - packet.readerIndex)); + player.updateFlags.addChatMessage({ color, effects, data }); +}; diff --git a/src/net/incoming-packets/command-packet.ts b/src/net/incoming-packets/command-packet.ts new file mode 100644 index 000000000..484f75f54 --- /dev/null +++ b/src/net/incoming-packets/command-packet.ts @@ -0,0 +1,20 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { inputCommandAction } from '../../world/actor/player/action/input-command-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const commandPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const input = packet.getString(); + + if (!input || input.trim().length === 0) { + return; + } + const isConsole = packetId == 246; + + const args = input.trim().split(' '); + const command = args[0]; + + args.splice(0, 1); + + inputCommandAction(player, command, isConsole, args); +}; diff --git a/src/net/incoming-packets/drop-item-packet.ts b/src/net/incoming-packets/drop-item-packet.ts new file mode 100644 index 000000000..b93107600 --- /dev/null +++ b/src/net/incoming-packets/drop-item-packet.ts @@ -0,0 +1,13 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { itemAction } from '@server/world/actor/player/action/item-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const dropItemPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const widgetId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const containerId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const slot = packet.get('SHORT', 'UNSIGNED'); + const itemId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + + itemAction(player, itemId, slot, widgetId, containerId, 'drop'); +}; diff --git a/src/net/incoming-packets/interface-click-packet.ts b/src/net/incoming-packets/interface-click-packet.ts new file mode 100644 index 000000000..82706afaf --- /dev/null +++ b/src/net/incoming-packets/interface-click-packet.ts @@ -0,0 +1,7 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const interfaceClickPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + // Do nothing +}; diff --git a/src/net/incoming-packets/item-equip-packet.ts b/src/net/incoming-packets/item-equip-packet.ts new file mode 100644 index 000000000..449156284 --- /dev/null +++ b/src/net/incoming-packets/item-equip-packet.ts @@ -0,0 +1,13 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { itemAction } from '@server/world/actor/player/action/item-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const itemEquipPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const containerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const widgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const slot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const itemId = packet.get('SHORT', 'UNSIGNED'); + + itemAction(player, itemId, slot, widgetId, containerId, 'equip'); +}; diff --git a/src/net/incoming-packets/item-interaction-packet.ts b/src/net/incoming-packets/item-interaction-packet.ts new file mode 100644 index 000000000..a98ac8887 --- /dev/null +++ b/src/net/incoming-packets/item-interaction-packet.ts @@ -0,0 +1,69 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { itemAction } from '@server/world/actor/player/action/item-action'; +import { getItemOption } from '@server/world/items/item'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +interface ItemInteraction { + widgetId: number; + containerId: number; + itemId: number; + slot: number; +} + +const option1 = (packet: ByteBuffer): ItemInteraction => { + const itemId = packet.get('SHORT', 'UNSIGNED'); + const slot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const widgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const containerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + return { widgetId, containerId, itemId, slot }; +}; + +const option2 = (packet: ByteBuffer): ItemInteraction => { + const itemId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const containerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const widgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const slot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + return { widgetId, containerId, itemId, slot }; +}; + +const option3 = (packet: ByteBuffer): ItemInteraction => { + const slot = packet.get('SHORT', 'UNSIGNED'); + const containerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const widgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const itemId = packet.get('SHORT', 'UNSIGNED'); + return { widgetId, containerId, itemId, slot }; +}; + +const option4 = (packet: ByteBuffer): ItemInteraction => { + const itemId = packet.get('SHORT', 'UNSIGNED'); + const slot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const containerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const widgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + return { widgetId, containerId, itemId, slot }; +}; + +const inventoryOption4 = (packet: ByteBuffer): ItemInteraction => { + const slot = packet.get('SHORT', 'UNSIGNED'); + const widgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const containerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const itemId = packet.get('SHORT', 'UNSIGNED'); + return { widgetId, containerId, itemId, slot }; +}; + +export const itemInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const packets = { + 38: { packetDef: option1, optionNumber: 1 }, + 228: { packetDef: option2, optionNumber: 2 }, + 26: { packetDef: option3, optionNumber: 3 }, + 147: { packetDef: option4, optionNumber: 4 }, + 98: { packetDef: inventoryOption4, optionNumber: 4 }, + }; + + const packetDetails = packets[packetId]; + const { widgetId, containerId, itemId, slot } = packetDetails.packetDef(packet); + + const option = getItemOption(itemId, packetDetails.optionNumber, { widgetId, containerId }); + + itemAction(player, itemId, slot, widgetId, containerId, option); +}; diff --git a/src/net/incoming-packets/item-on-item-packet.ts b/src/net/incoming-packets/item-on-item-packet.ts new file mode 100644 index 000000000..7784b4307 --- /dev/null +++ b/src/net/incoming-packets/item-on-item-packet.ts @@ -0,0 +1,38 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { widgets } from '@server/world/config/widget'; +import { logger } from '@runejs/logger/dist/logger'; +import { itemOnItemAction } from '@server/world/actor/player/action/item-on-item-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const itemOnItemPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const usedWithItemId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const usedWithSlot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const usedWithContainerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const usedWithWidgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const usedContainerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const usedWidgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const usedItemId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const usedSlot = packet.get('SHORT', 'UNSIGNED'); + + if(usedWidgetId === widgets.inventory.widgetId && usedContainerId === widgets.inventory.containerId && + usedWithWidgetId === widgets.inventory.widgetId && usedWithContainerId === widgets.inventory.containerId) { + if(usedSlot < 0 || usedSlot > 27 || usedWithSlot < 0 || usedWithSlot > 27) { + return; + } + + const usedItem = player.inventory.items[usedSlot]; + const usedWithItem = player.inventory.items[usedWithSlot]; + if(!usedItem || !usedWithItem) { + return; + } + + if(usedItem.itemId !== usedItemId || usedWithItem.itemId !== usedWithItemId) { + return; + } + + itemOnItemAction(player, usedItem, usedSlot, usedWidgetId, usedWithItem, usedWithSlot, usedWithWidgetId); + } else { + logger.warn(`Unhandled item on item case using widgets ${usedWidgetId}:${usedContainerId} => ${usedWithWidgetId}:${usedWithContainerId}`); + } +}; diff --git a/src/net/incoming-packets/item-on-npc-packet.ts b/src/net/incoming-packets/item-on-npc-packet.ts new file mode 100644 index 000000000..9b4954f1d --- /dev/null +++ b/src/net/incoming-packets/item-on-npc-packet.ts @@ -0,0 +1,55 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { widgets } from '@server/world/config/widget'; +import { logger } from '@runejs/logger/dist/logger'; +import { world } from '@server/game-server'; +import { World } from '@server/world/world'; +import { itemOnNpcAction } from '@server/world/actor/player/action/item-on-npc-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const itemOnNpcPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const npcIndex = packet.get('SHORT', 'UNSIGNED'); + const itemId = packet.get('SHORT', 'UNSIGNED'); + const itemSlot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const itemWidgetId = packet.get('SHORT'); + const itemContainerId = packet.get('SHORT'); + + let usedItem; + if(itemWidgetId === widgets.inventory.widgetId && itemContainerId === widgets.inventory.containerId) { + if(itemSlot < 0 || itemSlot > 27) { + return; + } + + usedItem = player.inventory.items[itemSlot]; + if(!usedItem) { + return; + } + + if(usedItem.itemId !== itemId) { + return; + } + } else { + logger.warn(`Unhandled item on object case using widget ${ itemWidgetId }:${ itemContainerId }`); + } + + + if(npcIndex < 0 || npcIndex > World.MAX_NPCS - 1) { + return; + } + + const npc = world.npcList[npcIndex]; + if(!npc) { + return; + } + + const position = npc.position; + const distance = Math.floor(position.distanceBetween(player.position)); + + // Too far away + if(distance > 16) { + return; + } + + itemOnNpcAction(player, npc, position, usedItem, itemWidgetId, itemContainerId); + +}; diff --git a/src/net/incoming-packets/item-on-object-packet.ts b/src/net/incoming-packets/item-on-object-packet.ts new file mode 100644 index 000000000..605bacd7a --- /dev/null +++ b/src/net/incoming-packets/item-on-object-packet.ts @@ -0,0 +1,61 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { widgets } from '@server/world/config/widget'; +import { logger } from '@runejs/logger/dist/logger'; +import { Position } from '@server/world/position'; +import { cache, world } from '@server/game-server'; +import { itemOnObjectAction } from '@server/world/actor/player/action/item-on-object-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const itemOnObjectPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const objectY = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const itemId = packet.get('SHORT', 'UNSIGNED'); + const objectId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const itemSlot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const itemWidgetId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const itemContainerId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + const objectX = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + + let usedItem; + if (itemWidgetId === widgets.inventory.widgetId && itemContainerId === widgets.inventory.containerId) { + if (itemSlot < 0 || itemSlot > 27) { + return; + } + + usedItem = player.inventory.items[itemSlot]; + if (!usedItem) { + return; + } + + if (usedItem.itemId !== itemId) { + return; + } + } else { + logger.warn(`Unhandled item on object case using widget ${itemWidgetId}:${itemContainerId}`); + } + const level = player.position.level; + + const objectPosition = new Position(objectX, objectY, level); + const objectChunk = world.chunkManager.getChunkForWorldPosition(objectPosition); + let cacheOriginal: boolean = true; + + let locationObject = objectChunk.getCacheObject(objectId, objectPosition); + if (!locationObject) { + locationObject = objectChunk.getAddedObject(objectId, objectPosition); + cacheOriginal = false; + + if (!locationObject) { + return; + } + } + + if (objectChunk.getRemovedObject(objectId, objectPosition)) { + return; + } + + const locationObjectDefinition = cache.locationObjectDefinitions.get(objectId); + + + itemOnObjectAction(player, locationObject, locationObjectDefinition, objectPosition, usedItem, itemWidgetId, itemContainerId, cacheOriginal); + +}; diff --git a/src/net/incoming-packets/item-swap-packet.ts b/src/net/incoming-packets/item-swap-packet.ts new file mode 100644 index 000000000..5731ae4e2 --- /dev/null +++ b/src/net/incoming-packets/item-swap-packet.ts @@ -0,0 +1,23 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { swapItemAction } from '../../world/actor/player/action/swap-item-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const itemSwapPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const swapType = packet.get(); + const fromSlot = packet.get('SHORT', 'UNSIGNED'); + const toSlot = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const containerId = packet.get('SHORT'); + const widgetId = packet.get('SHORT'); + + if(toSlot < 0 || fromSlot < 0) { + return; + } + + if(swapType === 0) { + // Swap + swapItemAction(player, fromSlot, toSlot, { widgetId, containerId }); + } else if(swapType === 1) { + // @TODO insert + } +}; diff --git a/src/world/mob/player/packet/impl/npc-interaction-packet.ts b/src/net/incoming-packets/npc-interaction-packet.ts similarity index 66% rename from src/world/mob/player/packet/impl/npc-interaction-packet.ts rename to src/net/incoming-packets/npc-interaction-packet.ts index 92c2dc110..eaf5ac290 100644 --- a/src/world/mob/player/packet/impl/npc-interaction-packet.ts +++ b/src/net/incoming-packets/npc-interaction-packet.ts @@ -1,20 +1,20 @@ import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; +import { Player } from '../../world/actor/player/player'; import { world } from '@server/game-server'; import { World } from '@server/world/world'; -import { npcAction } from '@server/world/mob/player/action/npc-action'; +import { npcAction } from '@server/world/actor/player/action/npc-action'; import { logger } from '@runejs/logger/dist/logger'; +import { ByteBuffer } from '@runejs/byte-buffer'; -export const npcInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const methods = { - 67: 'readNegativeOffsetShortBE', - 112: 'readUnsignedShortLE', - 13: 'readNegativeOffsetShortLE', +export const npcInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const args = { + 63: [ 'SHORT', 'UNSIGNED', 'LITTLE_ENDIAN' ], + 116: [ 'SHORT', 'UNSIGNED', 'LITTLE_ENDIAN' ], + /*13: 'readNegativeOffsetShortLE', 42: 'readUnsignedShortLE', - 8: 'readUnsignedShortLE' + 8: 'readUnsignedShortLE'*/ }; - const npcIndex = packet[methods[packetId]](); + const npcIndex = packet.get(...args[packetId]); if(npcIndex < 0 || npcIndex > World.MAX_NPCS - 1) { return; @@ -34,11 +34,11 @@ export const npcInteractionPacket: incomingPacket = (player: Player, packetId: n } const actions = { - 112: 0, // Usually the Talk-to option - 67: 1, // Usually the Attack option - 13: 2, // Usually the Pickpocket option - 42: 3, - 8: 4 + 63: 0, // Usually the Talk-to option + //67: 1, // Usually the Attack option + 116: 2, // Usually the Pickpocket option + /*42: 3, + 8: 4*/ }; const actionIdx = actions[packetId]; diff --git a/src/net/incoming-packets/number-input-packet.ts b/src/net/incoming-packets/number-input-packet.ts new file mode 100644 index 000000000..2a707d63c --- /dev/null +++ b/src/net/incoming-packets/number-input-packet.ts @@ -0,0 +1,8 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const numberInputPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const input = packet.get('INT', 'UNSIGNED'); + player.numericInputEvent.next(input); +}; diff --git a/src/net/incoming-packets/object-interaction-packet.ts b/src/net/incoming-packets/object-interaction-packet.ts new file mode 100644 index 000000000..a1fa0e2fe --- /dev/null +++ b/src/net/incoming-packets/object-interaction-packet.ts @@ -0,0 +1,101 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { Position } from '@server/world/position'; +import { cache, world } from '@server/game-server'; +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { logger } from '@runejs/logger/dist/logger'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +interface ObjectInteraction { + objectId: number; + x: number; + y: number; +} + +const option1 = (packet: ByteBuffer): ObjectInteraction => { + const objectId = packet.get('SHORT', 'UNSIGNED'); + const y = packet.get('SHORT', 'UNSIGNED'); + const x = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + return { objectId, x, y }; +}; + +const option2 = (packet: ByteBuffer): ObjectInteraction => { + const x = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const y = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const objectId = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + return { objectId, x, y }; +}; + +const option3 = (packet: ByteBuffer): ObjectInteraction => { + const y = packet.get('SHORT', 'UNSIGNED'); + const objectId = packet.get('SHORT', 'UNSIGNED'); + const x = packet.get('SHORT', 'UNSIGNED'); + return { objectId, x, y }; +}; + +// @TODO +const option4 = (packet: ByteBuffer): ObjectInteraction => { + const x = null; + const y = null; + const objectId = null; + return { objectId, x, y }; +}; + +// @TODO +const option5 = (packet: ByteBuffer): ObjectInteraction => { + const objectId = null; + const y = null; + const x = null; + return { objectId, x, y }; +}; + +export const objectInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const options = { + 30: { packetDef: option1, index: 0 }, + 164: { packetDef: option2, index: 1 }, + 183: { packetDef: option3, index: 2 }, + /*136: { packetDef: option4, index: 3 }, + 55: { packetDef: option5, index: 4 },*/ + }; + + const { objectId, x, y } = options[packetId].packetDef(packet); + const level = player.position.level; + + const objectPosition = new Position(x, y, level); + const objectChunk = world.chunkManager.getChunkForWorldPosition(objectPosition); + let cacheOriginal: boolean = true; + + let locationObject = objectChunk.getCacheObject(objectId, objectPosition); + if(!locationObject) { + locationObject = objectChunk.getAddedObject(objectId, objectPosition); + cacheOriginal = false; + + if(!locationObject) { + return; + } + } + + if(objectChunk.getRemovedObject(objectId, objectPosition)) { + return; + } + + const locationObjectDefinition = cache.locationObjectDefinitions.get(objectId); + + const actionIdx = options[packetId].index; + let optionName = `action-${actionIdx + 1}`; + if(locationObjectDefinition.options && locationObjectDefinition.options.length >= actionIdx) { + if(!locationObjectDefinition.options[actionIdx] || locationObjectDefinition.options[actionIdx].toLowerCase() === 'hidden') { + // Invalid action + logger.error(`1: Invalid object ${objectId} option ${actionIdx + 1}, options: ${JSON.stringify(locationObjectDefinition.options)}`); + return; + } + + optionName = locationObjectDefinition.options[actionIdx]; + } else { + // Invalid action + logger.error(`2: Invalid object ${objectId} option ${actionIdx + 1}, options: ${JSON.stringify(locationObjectDefinition.options)}`); + return; + } + + objectAction(player, locationObject, locationObjectDefinition, objectPosition, optionName.toLowerCase(), cacheOriginal); +}; diff --git a/src/net/incoming-packets/pickup-item-packet.ts b/src/net/incoming-packets/pickup-item-packet.ts new file mode 100644 index 000000000..977615a26 --- /dev/null +++ b/src/net/incoming-packets/pickup-item-packet.ts @@ -0,0 +1,27 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { world } from '@server/game-server'; +import { Position } from '@server/world/position'; +import { worldItemAction } from '@server/world/actor/player/action/world-item-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const pickupItemPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const y = packet.get('SHORT', 'UNSIGNED'); + const itemId = packet.get('SHORT', 'UNSIGNED'); + const x = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + + const level = player.position.level; + const worldItemPosition = new Position(x, y, level); + const chunk = world.chunkManager.getChunkForWorldPosition(worldItemPosition); + const worldItem = chunk.getWorldItem(itemId, worldItemPosition); + + if(!worldItem || worldItem.removed) { + return; + } + + if(worldItem.initiallyVisibleTo && !worldItem.initiallyVisibleTo.equals(player)) { + return; + } + + worldItemAction(player, worldItem, 'pick-up'); +}; diff --git a/src/world/mob/player/packet/impl/walk-packet.ts b/src/net/incoming-packets/walk-packet.ts similarity index 51% rename from src/world/mob/player/packet/impl/walk-packet.ts rename to src/net/incoming-packets/walk-packet.ts index 3d4c84d1d..dc9372661 100644 --- a/src/world/mob/player/packet/impl/walk-packet.ts +++ b/src/net/incoming-packets/walk-packet.ts @@ -1,19 +1,18 @@ -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; +import { Player } from '../../world/actor/player/player'; import { incomingPacket } from '../incoming-packet'; +import { ByteBuffer } from '@runejs/byte-buffer'; -export const walkPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { +export const walkPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { let size = packetSize; - - if(packetId === 213) { + if(packetId == 236) { size -= 14; } const totalSteps = Math.floor((size - 5) / 2); - const firstX = packet.readNegativeOffsetShortLE(); - const runSteps = packet.readByte() === 1; // @TODO ? - const firstY = packet.readNegativeOffsetShortLE(); + const firstY = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); + const runSteps = packet.get() === 1; // @TODO forced running + const firstX = packet.get('SHORT', 'UNSIGNED', 'LITTLE_ENDIAN'); const walkingQueue = player.walkingQueue; @@ -23,8 +22,8 @@ export const walkPacket: incomingPacket = (player: Player, packetId: number, pac walkingQueue.add(firstX, firstY); for(let i = 0; i < totalSteps; i++) { - const x = packet.readByte(); - const y = packet.readPreNegativeOffsetByte(); + const x = packet.get(); + const y = packet.get(); walkingQueue.add(x + firstX, y + firstY); } }; diff --git a/src/net/incoming-packets/widget-interaction-packet.ts b/src/net/incoming-packets/widget-interaction-packet.ts new file mode 100644 index 000000000..9ca6b6de7 --- /dev/null +++ b/src/net/incoming-packets/widget-interaction-packet.ts @@ -0,0 +1,12 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { widgetAction } from '@server/world/actor/player/action/widget-action'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const widgetInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + const childId = packet.get('SHORT'); + const widgetId = packet.get('SHORT'); + const optionId = packet.get('SHORT', 'SIGNED', 'LITTLE_ENDIAN'); + + widgetAction(player, widgetId, childId, optionId); +}; diff --git a/src/net/incoming-packets/widgets-closed-packet.ts b/src/net/incoming-packets/widgets-closed-packet.ts new file mode 100644 index 000000000..310390245 --- /dev/null +++ b/src/net/incoming-packets/widgets-closed-packet.ts @@ -0,0 +1,7 @@ +import { incomingPacket } from '../incoming-packet'; +import { Player } from '../../world/actor/player/player'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +export const widgetsClosedPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: ByteBuffer): void => { + player.closeActiveWidgets(false); +}; diff --git a/src/net/outgoing-packets.ts b/src/net/outgoing-packets.ts new file mode 100644 index 000000000..f62bd093b --- /dev/null +++ b/src/net/outgoing-packets.ts @@ -0,0 +1,507 @@ +import { Player } from '../world/actor/player/player'; +import { Socket } from 'net'; +import { Packet, PacketType } from '@server/net/packet'; +import { ItemContainer } from '@server/world/items/item-container'; +import { Item } from '@server/world/items/item'; +import { Position } from '@server/world/position'; +import { LocationObject } from '@runejs/cache-parser'; +import { Chunk, ChunkUpdateItem } from '@server/world/map/chunk'; +import { WorldItem } from '@server/world/items/world-item'; + +/** + * A helper class for sending various network packets back to the game client. + */ +export class OutgoingPackets { + + private updatingQueue: Buffer[]; + private packetQueue: Buffer[]; + private readonly player: Player; + private readonly socket: Socket; + + public constructor(player: Player) { + this.updatingQueue = []; + this.packetQueue = []; + this.player = player; + this.socket = player.socket; + } + + public playSong(songId: number): void { + const packet = new Packet(217); + packet.put(songId, 'SHORT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public playQuickSong(songId: number, previousSongId: number): void { + const packet = new Packet(40); + packet.put(previousSongId, 'INT24'); + packet.put(songId, 'SHORT'); + + this.queue(packet); + } + + public playSound(soundId: number, volume: number, delay: number = 0): void { + const packet = new Packet(131); + packet.put(soundId, 'SHORT'); + packet.put(volume); + packet.put(delay, 'SHORT'); + + this.queue(packet); + } + + public playSoundAtPosition(soundId: number, soundX: number, soundY: number, volume: number, radius: number = 5, delay: number = 0): void { + const packet = new Packet(9); + const offset = 0; + packet.put(offset, 'BYTE'); + packet.put(soundId, 'SHORT'); + packet.put((volume & 7) + (radius << 4), 'BYTE'); + packet.put(delay, 'BYTE'); + + this.queue(packet); + } + + private getChunkPositionOffset(x: number, y: number, chunk: Chunk): number { + const offsetX = x - ((chunk.position.x + 6) * 8); + const offsetY = y - ((chunk.position.y + 6) * 8); + return (offsetX * 16 + offsetY); + } + + private getChunkOffset(chunk: Chunk): { offsetX: number, offsetY: number } { + let offsetX = (chunk.position.x + 6) * 8; + let offsetY = (chunk.position.y + 6) * 8; + offsetX -= (this.player.lastMapRegionUpdatePosition.chunkX * 8); + offsetY -= (this.player.lastMapRegionUpdatePosition.chunkY * 8); + + return { offsetX, offsetY }; + } + + public updateChunk(chunk: Chunk, chunkUpdates: ChunkUpdateItem[]): void { + const { offsetX, offsetY } = this.getChunkOffset(chunk); + + const packet = new Packet(63, PacketType.DYNAMIC_LARGE); + packet.put(offsetX); + packet.put(offsetY); + + chunkUpdates.forEach(update => { + if(update.type === 'ADD') { + if(update.object) { + const offset = this.getChunkPositionOffset(update.object.x, update.object.y, chunk); + packet.put(241, 'BYTE'); + packet.put((update.object.type << 2) + (update.object.orientation & 3)); + packet.put(update.object.objectId, 'SHORT'); + packet.put(offset); + } else if(update.worldItem) { + const offset = this.getChunkPositionOffset(update.worldItem.position.x, update.worldItem.position.y, chunk); + packet.put(175, 'BYTE'); + packet.put(update.worldItem.itemId, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(update.worldItem.amount, 'SHORT'); + packet.put(offset, 'BYTE'); + } + } else if(update.type === 'REMOVE') { + const offset = this.getChunkPositionOffset(update.object.x, update.object.y, chunk); + packet.put(143, 'BYTE'); + packet.put(offset); + packet.put((update.object.type << 2) + (update.object.orientation & 3)); + } + }); + + this.queue(packet); + } + + public clearChunk(chunk: Chunk): void { + const { offsetX, offsetY } = this.getChunkOffset(chunk); + + const packet = new Packet(64); + packet.put(offsetY, 'BYTE'); + packet.put(offsetX); + + this.queue(packet); + } + + public setWorldItem(worldItem: WorldItem, position: Position, offset: number = 0): void { + this.updateReferencePosition(position); + + const packet = new Packet(175); + packet.put(worldItem.itemId, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(worldItem.amount, 'SHORT'); + packet.put(offset, 'BYTE'); + + this.queue(packet); + } + + public removeWorldItem(worldItem: WorldItem, position: Position, offset: number = 0): void { + this.updateReferencePosition(position); + + const packet = new Packet(74); + packet.put(offset, 'BYTE'); + packet.put(worldItem.itemId, 'SHORT'); + + this.queue(packet); + } + + public setLocationObject(locationObject: LocationObject, position: Position, offset: number = 0): void { + this.updateReferencePosition(position); + + const packet = new Packet(241); + packet.put((locationObject.type << 2) + (locationObject.orientation & 3)); + packet.put(locationObject.objectId, 'SHORT'); + packet.put(offset); + + this.queue(packet); + } + + public removeLocationObject(locationObject: LocationObject, position: Position, offset: number = 0): void { + this.updateReferencePosition(position); + + const packet = new Packet(143); + packet.put(offset); + packet.put((locationObject.type << 2) + (locationObject.orientation & 3)); + + this.queue(packet); + } + + public updateReferencePosition(position: Position): void { + const offsetX = position.x - (this.player.lastMapRegionUpdatePosition.chunkX * 8); + const offsetY = position.y - (this.player.lastMapRegionUpdatePosition.chunkY * 8); + + const packet = new Packet(254); + packet.put(offsetY); + packet.put(offsetX); + + this.queue(packet); + } + + // Text dialogs = 356, 359, 363, 368, 374 + // Item dialogs = 519 + // Statements (no click to continue) = 210, 211, 212, 213, 214 + public showChatboxWidget(widgetId: number): void { + const packet = new Packet(208); + packet.put(widgetId, 'SHORT'); + + this.queue(packet); + } + + public setWidgetNpcHead(widgetId: number, childId: number, modelId: number): void { + const packet = new Packet(160); + packet.put(modelId, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public setWidgetPlayerHead(widgetId: number, childId: number): void { + const packet = new Packet(210); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public playWidgetAnimation(widgetId: number, childId: number, animationId: number): void { + const packet = new Packet(24); + packet.put(animationId, 'SHORT'); + packet.put(widgetId << 16 | childId, 'INT'); + + this.queue(packet); + } + + public showScreenAndTabWidgets(widgetId: number, tabWidgetId: number): void { + const packet = new Packet(84); + packet.put(tabWidgetId, 'SHORT'); + packet.put(widgetId, 'SHORT', 'LITTLE_ENDIAN'); + this.queue(packet); + } + + public updateClientConfig(configId: number, value: number): void { + let packet: Packet; + + if(value > 128) { + packet = new Packet(2); + packet.put(value, 'INT'); + packet.put(configId, 'SHORT'); + } else { + packet = new Packet(222); + packet.put(value); + packet.put(configId, 'SHORT'); + } + + this.queue(packet); + } + + public setWidgetModelRotationAndZoom(widgetId: number, childId: number, rotationX: number, rotationY: number, zoom: number): void { + const packet = new Packet(142); + packet.put(rotationX, 'SHORT'); + packet.put(zoom, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(rotationY, 'SHORT'); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public updateWidgetModel1(widgetId: number, childId: number, modelId: number): void { + const packet = new Packet(250); + packet.put(modelId, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public updateWidgetItemModel(widgetId: number, itemId: number, scale?: number): void { + const packet = new Packet(21); + packet.put(scale, 'SHORT'); + packet.put(itemId, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(widgetId, 'SHORT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public updateWidgetString(widgetId: number, childId: number, value: string): void { + const packet = new Packet(110, PacketType.DYNAMIC_LARGE); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + packet.putString(value); + + this.queue(packet); + } + + public updateWidgetColor(widgetId: number, childId: number, color: number): void { + const packet = new Packet(231); + packet.put(color, 'SHORT'); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public closeActiveWidgets(): void { + this.queue(new Packet(180)); + } + + public showScreenWidget(widgetId: number): void { + const packet = new Packet(118); + packet.put(widgetId, 'SHORT'); + + this.queue(packet); + } + + // @TODO this can support multiple items/slots !!! + public sendUpdateSingleWidgetItem(widget: { widgetId: number, containerId: number }, slot: number, item: Item): void { + const packet = new Packet(214, PacketType.DYNAMIC_LARGE); + packet.put(widget.widgetId << 16 | widget.containerId, 'INT'); + packet.put(slot, 'SMART'); + + if(!item) { + packet.put(0, 'SHORT'); + } else { + packet.put(item.itemId + 1, 'SHORT'); // +1 because 0 means an empty slot + + if(item.amount >= 255) { + packet.put(255, 'BYTE'); + packet.put(item.amount, 'INT'); + } else { + packet.put(item.amount, 'BYTE'); + } + } + + this.queue(packet); + } + + public sendUpdateAllWidgetItems(widget: { widgetId: number, containerId: number }, container: ItemContainer): void { + const packet = new Packet(12, PacketType.DYNAMIC_LARGE); + packet.put(widget.widgetId << 16 | widget.containerId, 'INT'); + packet.put(container.size, 'SHORT'); + + const items = container.items; + items.forEach(item => { + if(!item) { + // Empty slot + packet.put(0); + packet.put(0, 'SHORT'); + } else { + if(item.amount >= 255) { + packet.put(255); + packet.put(item.amount, 'INT'); + } else { + packet.put(item.amount); + } + + packet.put(item.itemId + 1, 'SHORT'); // +1 because 0 means an empty slot + } + }); + + this.queue(packet); + } + + public sendUpdateAllWidgetItemsById(widget: { widgetId: number, containerId: number }, itemIds: number[]): void { + const packet = new Packet(12, PacketType.DYNAMIC_LARGE); + packet.put(widget.widgetId << 16 | widget.containerId, 'INT'); + packet.put(itemIds.length, 'SHORT'); + + itemIds.forEach(itemId => { + if(!itemId) { + // Empty slot + packet.put(0); + packet.put(0, 'SHORT'); + } else { + packet.put(1); + packet.put(itemId + 1, 'SHORT'); // +1 because 0 means an empty slot + } + }); + + this.queue(packet); + } + + public setItemOnWidget(widgetId: number, childId: number, itemId: number, zoom: number): void { + const packet = new Packet(120); + packet.put(zoom, 'SHORT'); + packet.put(itemId, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public toggleWidgetVisibility(widgetId: number, childId: number, hidden: boolean): void { + const packet = new Packet(115); + packet.put(hidden ? 1 : 0, 'BYTE'); + packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public moveWidgetChild(widgetId: number, childId: number, offsetX: number, offsetY: number): void { + const packet = new Packet(3); + packet.put(widgetId << 16 | childId, 'INT'); + packet.put(offsetY, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(offsetX, 'SHORT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public sendTabWidget(tabIndex: number, widgetId: number): void { + const packet = new Packet(140); + packet.put(widgetId, 'SHORT'); + packet.put(tabIndex); + + this.queue(packet); + } + + public showFullscreenWidget(widgetId: number, secondaryWidgetId: number): void { + const packet = new Packet(195); + packet.put(secondaryWidgetId, 'SHORT'); + packet.put(widgetId, 'SHORT'); + + this.queue(packet); + } + + public showNumberInputDialogue(): void { + const packet = new Packet(132); + this.queue(packet); + } + + public showTextInputDialogue(): void { + const packet = new Packet(124); + this.queue(packet); + } + + public updateCarryWeight(weight: number): void { + const packet = new Packet(171); + packet.put(weight, 'SHORT'); + + this.queue(packet); + } + + public showHintIcon(iconType: 2 | 3 | 4 | 5 | 6, position: Position, offset: number = 0): void { + const packet = new Packet(199); + packet.put(iconType, 'BYTE'); + packet.put(position.x, 'SHORT'); + packet.put(position.y, 'SHORT'); + packet.put(offset, 'BYTE'); + + this.queue(packet); + } + + public showPlayerHintIcon(player: Player): void { + const packet = new Packet(199); + packet.put(10, 'BYTE'); + packet.put(player.worldIndex, 'SHORT'); + + // Packet requires a length of 6, so send some extra junk + packet.put(0); + packet.put(0); + packet.put(0); + + this.queue(packet); + } + + public logout(): void { + this.packetQueue = []; + this.updatingQueue = []; + + this.socket.write(new Packet(181).toBuffer(this.player.outCipher)); + } + + public chatboxMessage(message: string): void { + const packet = new Packet(82, PacketType.DYNAMIC_SMALL); + packet.putString(message); + + this.queue(packet); + } + + public consoleMessage(message: string): void { + const packet = new Packet(83, PacketType.DYNAMIC_SMALL); + packet.putString(message); + + this.queue(packet); + } + + public updateSkill(skillId: number, level: number, exp: number): void { + const packet = new Packet(34); + packet.put(level); + packet.put(skillId); + packet.put(exp, 'INT', 'LITTLE_ENDIAN'); + + this.queue(packet); + } + + public updateCurrentMapChunk(): void { + const packet = new Packet(166, PacketType.DYNAMIC_LARGE); + packet.put(this.player.position.chunkLocalY, 'SHORT'); + packet.put(this.player.position.chunkX + 6, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(this.player.position.chunkLocalX, 'SHORT'); + packet.put(this.player.position.chunkY + 6, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(this.player.position.level); + + for(let xCalc = Math.floor(this.player.position.chunkX / 8); xCalc <= Math.floor((this.player.position.chunkX + 12) / 8); xCalc++) { + for(let yCalc = Math.floor(this.player.position.chunkY / 8); yCalc <= Math.floor((this.player.position.chunkY + 12) / 8); yCalc++) { + for(let seeds = 0; seeds < 4; seeds++) { + packet.put(0, 'INT'); + } + } + } + + this.queue(packet); + } + + public flushQueue(): void { + if(!this.socket || this.socket.destroyed) { + return; + } + + const buffer = Buffer.concat([...this.packetQueue, ...this.updatingQueue]); + if(buffer.length !== 0) { + this.socket.write(buffer); + } + + this.updatingQueue = []; + this.packetQueue = []; + } + + public queue(packet: Packet, updateTask: boolean = false): void { + if(!this.socket || this.socket.destroyed) { + return; + } + + const queue = updateTask ? this.updatingQueue : this.packetQueue; + + const packetBuffer = packet.toBuffer(this.player.outCipher); + queue.push(packetBuffer); + } + +} diff --git a/src/net/packet.ts b/src/net/packet.ts index 2c7eabee8..2babfe6d5 100644 --- a/src/net/packet.ts +++ b/src/net/packet.ts @@ -1,5 +1,5 @@ -import { RsBuffer } from './rs-buffer'; import { Isaac } from './isaac'; +import { ByteBuffer } from '@runejs/byte-buffer'; /** * The type of packet; Fixed, Dynamic Small (sized byte), or Dynamic Large (sized short) @@ -13,47 +13,47 @@ export enum PacketType { /** * A single packet to be sent to the game client. */ -export class Packet extends RsBuffer { +export class Packet extends ByteBuffer { private readonly _packetId: number; private readonly _type: PacketType = PacketType.FIXED; public constructor(packetId: number, type: PacketType = PacketType.FIXED, allocatedSize: number = 5000) { - super(Buffer.alloc(allocatedSize)); + super(allocatedSize); this._packetId = packetId; this._type = type; } public toBuffer(cipher: Isaac): Buffer { - const packetSize = this.getWriterIndex(); + const packetSize = this.writerIndex; let bufferSize = packetSize + 1; // +1 for the packet id if(this.type !== PacketType.FIXED) { bufferSize += this.type === PacketType.DYNAMIC_SMALL ? 1 : 2; } - const buffer = RsBuffer.create(bufferSize); - buffer.writeUnsignedByte((this.packetId + (cipher !== null ? cipher.rand() : 0)) & 0xff); + const buffer = new ByteBuffer(bufferSize); + buffer.put((this.packetId + (cipher !== null ? cipher.rand() : 0)) & 0xff, 'BYTE'); let copyStart = 1; if(this.type === PacketType.DYNAMIC_SMALL) { - buffer.writeUnsignedByte(packetSize); + buffer.put(packetSize, 'BYTE'); copyStart = 2; } else if(this.type === PacketType.DYNAMIC_LARGE) { - buffer.writeShortBE(packetSize); + buffer.put(packetSize, 'SHORT'); copyStart = 3; } - this.getBuffer().copy(buffer.getBuffer(), copyStart, 0, packetSize); - return buffer.getBuffer(); + this.copy(buffer, copyStart, 0, packetSize); + return Buffer.from(buffer); } - get packetId(): number { + public get packetId(): number { return this._packetId; } - get type(): PacketType { + public get type(): PacketType { return this._type; } } diff --git a/src/net/rs-buffer.ts b/src/net/rs-buffer.ts deleted file mode 100644 index 089ef0274..000000000 --- a/src/net/rs-buffer.ts +++ /dev/null @@ -1,379 +0,0 @@ -const BIT_MASKS: number[] = []; - -for(let i = 0; i < 32; i++) { - BIT_MASKS.push((1 << i) - 1); -} - -export function stringToLong(s: string): bigint { - let l: bigint = BigInt(0); - - for(let i = 0; i < s.length && i < 12; i++) { - const c = s.charAt(i); - const cc = s.charCodeAt(i); - l *= BigInt(37); - if(c >= 'A' && c <= 'Z') l += BigInt((1 + cc) - 65); - else if(c >= 'a' && c <= 'z') l += BigInt((1 + cc) - 97); - else if(c >= '0' && c <= '9') l += BigInt((27 + cc) - 48); - } - while(l % BigInt(37) == BigInt(0) && l != BigInt(0)) l /= BigInt(37); - return l; -} - -/** - * Special snowflake byte buffer. - */ -export class RsBuffer { - - private buffer: Buffer; - private writerIndex: number = 0; - private readerIndex: number = 0; - private bitIndex: number; - - public constructor(buffer: Buffer) { - this.buffer = buffer; - } - - public static create(size: number = 5000): RsBuffer { - const buffer = Buffer.alloc(size); - return new RsBuffer(buffer); - } - - /** - * Enables the writing of specific bits to the buffer. - */ - public openBitChannel(): void { - this.bitIndex = this.writerIndex * 8; - } - - /** - * Disables the writing of specific bits to the buffer. - */ - public closeBitChannel(): void { - this.writerIndex = Math.floor((this.bitIndex + 7) / 8); - } - - /** - * Makes sure the current buffer has the specified space left within it. - * If not, a new buffer is created that contains the old buffer's data with the required space available at the end. - * @param remaining The required size remaining. - */ - public ensureCapacity(remaining: number): void { - if(this.getReadable() < remaining) { - const newBuffer = Buffer.alloc(remaining); - this.buffer.copy(newBuffer, 0, 0); - this.buffer = newBuffer; - } - } - - public ensureWritableCapacity(space: number): void { - if(this.getWritable() < this.writerIndex + space) { - const newBuffer = Buffer.alloc(this.writerIndex + space); - this.buffer.copy(newBuffer, 0, 0); - this.buffer = newBuffer; - } - } - - public writeBytes(fromBuffer: RsBuffer | Buffer): void { - if(fromBuffer instanceof RsBuffer) { - fromBuffer = fromBuffer.getData(); - } - - this.ensureCapacity(this.writerIndex + fromBuffer.length); - fromBuffer.copy(this.getBuffer(), this.getWriterIndex(), 0); - this.setWriterIndex(this.getWriterIndex() + fromBuffer.length); - } - - public writeBits(bitCount: number, value: number): void { - const byteCount: number = Math.ceil(bitCount / 8) + 1; - - this.ensureWritableCapacity((this.bitIndex + 7) / 8 + byteCount); - - let byteIndex: number = this.bitIndex >> 3; - let bitOffset: number = 8 - (this.bitIndex & 7); - - this.bitIndex += bitCount; - - for(; bitCount > bitOffset; bitOffset = 8) { - this.buffer[byteIndex] &= ~BIT_MASKS[bitOffset]; - this.buffer[byteIndex++] |= (value >> (bitCount - bitOffset)) & BIT_MASKS[bitOffset]; - bitCount -= bitOffset; - } - - if(bitCount == bitOffset) { - this.buffer[byteIndex] &= ~BIT_MASKS[bitOffset]; - this.buffer[byteIndex] |= value & BIT_MASKS[bitOffset]; - } else { - this.buffer[byteIndex] &= ~(BIT_MASKS[bitCount] << (bitOffset - bitCount)); - this.buffer[byteIndex] |= (value & BIT_MASKS[bitCount]) << (bitOffset - bitCount); - } - } - - public readUnsignedByte(): number { - return this.buffer.readUInt8(this.readerIndex++); - } - - public readByte(): number { - return this.buffer.readInt8(this.readerIndex++); - } - - public readByteInverted(): number { - return -this.buffer.readUInt8(this.readerIndex++); - } - - public readPreNegativeOffsetByte(): number { - return 128 - (this.readByte() & 0xff); - } - - public readPostNegativeOffsetByte(): number { - return (this.readByte() & 0xff) - 128; - } - - public readShortBE(): number { - const value = this.buffer.readInt16BE(this.readerIndex); - this.readerIndex += 2; - return value; - } - - public readShortLE(): number { - const value = this.buffer.readInt16LE(this.readerIndex); - this.readerIndex += 2; - return value; - } - - public readUnsignedShortBE(): number { - const value = this.buffer.readUInt16BE(this.readerIndex); - this.readerIndex += 2; - return value; - } - - public readUnsignedShortLE(): number { - const value = this.buffer.readUInt16LE(this.readerIndex); - this.readerIndex += 2; - return value; - } - - public readNegativeOffsetShortLE(): number { - let value = (this.readByte() - 128 & 0xff) | ((this.readByte() & 0xff) << 8); - if(value > 32767) { - value -= 0x10000; - } - - return value; - } - - public readNegativeOffsetShortBE(): number { - let value = ((this.readByte() & 0xff) << 8) | (this.readByte() - 128 & 0xff); - if(value > 32767) { - value -= 0x10000; - } - - return value; - } - - public readIntBE(): number { - const value = this.buffer.readInt32BE(this.readerIndex); - this.readerIndex += 4; - return value; - } - - public readLongBE(): bigint { - const value = this.buffer.readBigInt64BE(this.readerIndex); - this.readerIndex += 8; - return value; - } - - public readSmart(): number { - const peek = this.buffer.readUInt8(this.readerIndex); - if(peek < 128) { - return this.readUnsignedByte(); - } else { - return this.readUnsignedShortBE() - 32768; - } - } - - public readString(): string { - const bytes: number[] = []; - let b: number; - - while((b = this.readByte()) !== 10) { - bytes.push(b); - } - - return Buffer.from(bytes).toString(); - } - - public readBytes(length: number): Buffer { - const result = this.buffer.slice(this.readerIndex, this.readerIndex + length + 1); - this.readerIndex += length; - return result; - } - - public writeByte(value: number): void { - this.buffer.writeInt8(value, this.writerIndex++); - } - - public writeByteInverted(value: number): void { - this.writeByte(-value); - } - - public writeOffsetByte(value: number): void { - this.writeUnsignedByte(value + 128); - } - - public writeNegativeOffsetByte(value: number): void { - this.writeUnsignedByte(128 - value); - } - - public writeUnsignedByte(value: number): void { - this.buffer.writeUInt8(value, this.writerIndex++); - } - - public writeUnsignedByteInverted(value: number): void { - this.writeUnsignedByte(~value & 0xff); - } - - public writeUnsignedShortBE(value: number): void { - this.buffer.writeUInt16BE(value, this.writerIndex); - this.writerIndex += 2; - } - - public writeShortBE(value: number): void { - this.buffer.writeInt16BE(value, this.writerIndex); - this.writerIndex += 2; - } - - public writeShortLE(value: number): void { - this.buffer.writeInt16LE(value, this.writerIndex); - this.writerIndex += 2; - } - - public writeUnsignedShortLE(value: number): void { - this.buffer.writeUInt16LE(value, this.writerIndex); - this.writerIndex += 2; - } - - public writeOffsetShortBE(value: number): void { - this.writeUnsignedByte(value >> 8); - this.writeUnsignedByte(value + 128 & 0xff); - } - - public writeUnsignedOffsetShortBE(value: number): void { - this.writeUnsignedByte((value >> 8) & 0xff); - this.writeUnsignedByte(value + 128 & 0xff); - } - - public writeNegativeOffsetShortBE(value: number): void { - this.writeUnsignedByte(value >> 8); - this.writeUnsignedByte(value - 128 & 0xff); - } - - public writeOffsetShortLE(value: number): void { - this.writeUnsignedByte(value + 128 & 0xff); - this.writeUnsignedByte(value >> 8); - } - - public writeUnsignedOffsetShortLE(value: number): void { - this.writeUnsignedByte(value + 128 & 0xff); - this.writeUnsignedByte((value >> 8) & 0xff); - } - - public writeNegativeOffsetShortLE(value: number): void { - this.writeUnsignedByte(value - 128 & 0xff); - this.writeUnsignedByte(value >> 8); - } - - public writeMediumME(value: number): void { - this.writeUnsignedByte(value >> 8); - this.writeUnsignedByte(value >> 16); - this.writeUnsignedByte(value); - } - - public writeIntLE(value: number): void { - this.buffer.writeInt32LE(value, this.writerIndex); - this.writerIndex += 4; - } - - public writeIntBE(value: number): void { - this.buffer.writeInt32BE(value, this.writerIndex); - this.writerIndex += 4; - } - - public writeIntME1(value: number): void { - this.writeUnsignedByte((value >> 8) & 0xff); - this.writeUnsignedByte(value & 0xff); - this.writeUnsignedByte((value >> 24) & 0xff); - this.writeUnsignedByte((value >> 16) & 0xff); - } - - public writeLongBE(value: bigint): void { - this.buffer.writeBigInt64BE(value, this.writerIndex); - this.writerIndex += 8; - } - - public writeString(value: string): void { - const encoder = new TextEncoder(); - const bytes = encoder.encode(value); - - for(const byte of bytes) { - this.writeByte(byte); - } - - this.writeByte(10); // end of line - } - - public writeSmart(value: number): void { - if(value >= 128) { - this.writeShortBE(value); - } else { - this.writeByte(value); - } - } - - public getReadable(): number { - return this.buffer.length - this.readerIndex; - } - - public getWritable(): number { - return this.buffer.length - this.writerIndex; - } - - public getBuffer(): Buffer { - return this.buffer; - } - - /** - * Gets all of the data currently in the buffer, excluding past the current writer index. - */ - public getData(): Buffer { - return this.buffer.slice(0, this.writerIndex); - } - - public getUnreadData(): Buffer { - return this.buffer.slice(this.readerIndex, this.buffer.length + 1); - } - - public getSlice(position: number, length: number): RsBuffer { - return new RsBuffer(this.buffer.slice(position, position + length)); - } - - public flip(): RsBuffer { - this.buffer = this.getData().reverse(); - return this; - } - - public getWriterIndex(): number { - return this.writerIndex; - } - - public getReaderIndex(): number { - return this.readerIndex; - } - - public setWriterIndex(position: number): void { - this.writerIndex = position; - } - - public setReaderIndex(position: number): void { - this.readerIndex = position; - } -} diff --git a/src/plugins/buttons/dialogue-action-plugin.ts b/src/plugins/buttons/dialogue-action-plugin.ts deleted file mode 100644 index b742d2392..000000000 --- a/src/plugins/buttons/dialogue-action-plugin.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { buttonAction } from '@server/world/mob/player/action/button-action'; -import { ActionType, RunePlugin } from '@server/plugins/plugin'; - -const dialogueActions: { [key: number]: number } = { - 2494: 1, 2495: 2, 2496: 3, 2497: 4, 2498: 5, - 2482: 1, 2483: 2, 2484: 3, 2485: 4, - 2471: 1, 2472: 2, 2473: 3, - 2461: 1, 2462: 2 -}; - -const buttonIds = Object.keys(dialogueActions).map(Number); - -export const action: buttonAction = (details) => { - const { player, buttonId } = details; - player.dialogueInteractionEvent.next(dialogueActions[buttonId]); -}; - -export default new RunePlugin({ type: ActionType.BUTTON, buttonIds, action }); diff --git a/src/plugins/buttons/logout-button-plugin.ts b/src/plugins/buttons/logout-button-plugin.ts index acd107efd..e3d985587 100644 --- a/src/plugins/buttons/logout-button-plugin.ts +++ b/src/plugins/buttons/logout-button-plugin.ts @@ -1,9 +1,10 @@ -import { buttonAction } from '@server/world/mob/player/action/button-action'; +import { buttonAction } from '@server/world/actor/player/action/button-action'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; export const action: buttonAction = (details) => { const { player } = details; player.logout(); }; -export default new RunePlugin({ type: ActionType.BUTTON, buttonIds: 2458, action }); +export default new RunePlugin({ type: ActionType.BUTTON, widgetId: widgets.logoutTab, buttonIds: 6, action }); diff --git a/src/plugins/buttons/magic-teleports-plugin.ts b/src/plugins/buttons/magic-teleports-plugin.ts new file mode 100644 index 000000000..13ea6fbdd --- /dev/null +++ b/src/plugins/buttons/magic-teleports-plugin.ts @@ -0,0 +1,74 @@ +import { buttonAction } from '@server/world/actor/player/action/button-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { Player } from '@server/world/actor/player/player'; +import { loopingAction } from '@server/world/actor/player/action/action'; +import { Position } from '@server/world/position'; +import { animationIds } from '@server/world/config/animation-ids'; +import { soundIds } from '@server/world/config/sound-ids'; +import { gfxIds } from '@server/world/config/gfx-ids'; + +enum Teleports { + Home = 591, + Varrock = 12, + Lumbridge = 15, + Falador = 18, + Camelot = 22, + Ardougne = 388, + Watchtower = 389, + Trollheim = 492, + Ape_atoll = 569 +} + +const buttonIds: number[] = [ + 591, // Home Teleport +]; + +function HomeTeleport(player: Player): void { + let elapsedTicks = 0; + + const loop = loopingAction({ player }); + loop.event.subscribe(() => { + if (elapsedTicks === 0) { + player.playAnimation(animationIds.homeTeleportDraw); + player.playGraphics({id: gfxIds.homeTeleportDraw, delay: 0, height: 0}); + player.outgoingPackets.playSound(soundIds.homeTeleportDraw, 10); + } + if (elapsedTicks === 7) { + player.playAnimation(animationIds.homeTeleportPullOutAndReadBook); + player.outgoingPackets.playSound(soundIds.homeTeleportSit, 10); + } + if (elapsedTicks === 12) { + player.playAnimation(animationIds.homeTeleportSit); + player.playGraphics({id: gfxIds.homeTeleportPullOutBook, delay: 0, height: 0}); + player.outgoingPackets.playSound(soundIds.homeTeleportPullOutBook, 10); + } + if (elapsedTicks === 16) { + player.playAnimation(animationIds.homeTeleportReadBookAndGlowCircle); + player.playGraphics({id: gfxIds.homeTeleportCircleGlow, delay: 0, height: 0}); + player.outgoingPackets.playSound(soundIds.homeTeleportCircleGlowAndTeleport, 10); + + } + if (elapsedTicks === 20) { + player.playAnimation(animationIds.homeTeleport); + player.playGraphics({id: gfxIds.homeTeleport, delay: 0, height: 0}); + } + if (elapsedTicks === 22) { + player.teleport(new Position(3218, 3218)); + loop.cancel(); + return; + } + elapsedTicks++; + }); +} + +export const action: buttonAction = (details) => { + const {player, buttonId} = details; + + switch (buttonId) { + case Teleports.Home: + HomeTeleport(player); + break; + } +}; + +export default new RunePlugin({type: ActionType.BUTTON, widgetId: 192, buttonIds: buttonIds, action}); diff --git a/src/plugins/buttons/player-emotes-plugin.ts b/src/plugins/buttons/player-emotes-plugin.ts new file mode 100644 index 000000000..3afa41d14 --- /dev/null +++ b/src/plugins/buttons/player-emotes-plugin.ts @@ -0,0 +1,141 @@ +import { buttonAction } from '@server/world/actor/player/action/button-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; +import { Player } from '@server/world/actor/player/player'; + +interface Emote { + animationId: number; + name: string; + unlockable?: boolean; + graphicId?: number; +} + +export const emotes: { [key: number]: Emote } = { + 1: { animationId: 855, name: 'YES' }, + 2: { animationId: 856, name: 'NO' }, + 3: { animationId: 858, name: 'BOW' }, + 4: { animationId: 859, name: 'ANGRY' }, + 5: { animationId: 857, name: 'THINKING' }, + 6: { animationId: 863, name: 'WAVE' }, + 7: { animationId: 2113, name: 'SHRUG' }, + 8: { animationId: 862, name: 'CHEER' }, + 9: { animationId: 864, name: 'BECKON' }, + 10: { animationId: 861, name: 'LAUGH' }, + 11: { animationId: 2109, name: 'JUMP FOR JOY' }, + 12: { animationId: 2111, name: 'YAWN' }, + 13: { animationId: 866, name: 'DANCE' }, + 14: { animationId: 2106, name: 'JIG' }, + 15: { animationId: 2107, name: 'SPIN' }, + 16: { animationId: 2108, name: 'HEADBANG' }, + 17: { animationId: 860, name: 'CRY' }, + 18: { animationId: 1368, name: 'BLOW KISS' }, + 19: { animationId: 2105, name: 'PANIC' }, + 20: { animationId: 2110, name: 'RASPBERRY' }, + 21: { animationId: 865, name: 'CLAP' }, + 22: { animationId: 2112, name: 'SALUTE' }, + 23: { animationId: 2127, name: 'GOBLIN BOW', unlockable: true }, + 24: { animationId: 2128, name: 'GOBLIN SALUTE', unlockable: true }, + 25: { animationId: 1131, name: 'GLASS BOX', unlockable: true }, + 26: { animationId: 1130, name: 'CLIMB ROPE', unlockable: true }, + 27: { animationId: 1129, name: 'LEAN', unlockable: true }, + 28: { animationId: 1128, name: 'GLASS WALL', unlockable: true }, + 32: { animationId: 4276, name: 'IDEA', unlockable: true, graphicId: 712 }, + 30: { animationId: 4278, name: 'STAMP', unlockable: true }, + 31: { animationId: 4280, name: 'FLAP', unlockable: true }, + 29: { animationId: 4275, name: 'FACEPALM', unlockable: true }, + 33: { animationId: 3544, name: 'ZOMBIE WALK', unlockable: true }, + 34: { animationId: 3543, name: 'ZOMBIE DANCE', unlockable: true }, + 35: { animationId: 2836, name: 'SCARED', unlockable: true }, + 36: { animationId: 6111, name: 'RABBIT HOP', unlockable: true }, // @TODO missing in 435 cache??? + 37: { animationId: -1, name: 'SKILLCAPE' }, // @TODO skillcape emotes +}; + +export function unlockEmote(player: Player, emoteName: string): void { + const unlockedEmotes: string[] = player.savedMetadata.unlockedEmotes || []; + unlockedEmotes.push(emoteName); + player.savedMetadata.unlockedEmotes = unlockedEmotes; + unlockEmotes(player); +} + +export function lockEmote(player: Player, emoteName: string): void { + const unlockedEmotes: string[] = player.savedMetadata.unlockedEmotes || []; + const index = unlockedEmotes.indexOf(emoteName); + + if(index !== -1) { + unlockedEmotes.splice(index, 1); + player.savedMetadata.unlockedEmotes = unlockedEmotes; + unlockEmotes(player); + } +} + +export function unlockEmotes(player: Player): void { + let sosConfig = 0; + let eventConfig = 0; + let goblinConfig = 0; + + const unlockedEmotes: string[] = player.savedMetadata.unlockedEmotes || []; + + for(const name of unlockedEmotes) { + if((name === 'GOBLIN BOW' || name === 'GOBLIN SALUTE') && goblinConfig === 0) + goblinConfig += 7; + if(name === 'FLAP') + sosConfig += 1; + if(name === 'FACEPALM') + sosConfig += 2; + if(name === 'IDEA') + sosConfig += 4; + if(name === 'STAMP') + sosConfig += 8; + if(name === 'GLASS WALL') + eventConfig += 1; + if(name === 'GLASS BOX') + eventConfig += 2; + if(name === 'CLIMB ROPE') + eventConfig += 4; + if(name === 'LEAN') + eventConfig += 8; + if(name === 'SCARED') + eventConfig += 16; + if(name === 'ZOMBIE DANCE') + eventConfig += 32; + if(name === 'ZOMBIE WALK') + eventConfig += 64; + if(name === 'RABBIT HOP') + eventConfig += 128; + if(name === 'SKILLCAPE') + eventConfig += 256; + } + + player.outgoingPackets.updateClientConfig(465, goblinConfig); + player.outgoingPackets.updateClientConfig(802, sosConfig); + player.outgoingPackets.updateClientConfig(313, eventConfig); +} + +const buttonIds = Object.keys(emotes).map(v => parseInt(v)); + +export const action: buttonAction = (details) => { + const { player, buttonId } = details; + + const emote = emotes[buttonId]; + + if(emote.name === 'SKILLCAPE') { + player.sendMessage(`You need to be wearing a skillcape in order to perform that emote.`); + } else { + if(emote.unlockable) { + const unlockedEmotes: string[] = player.savedMetadata.unlockedEmotes || []; + + if(unlockedEmotes.indexOf(emote.name) === -1) { + player.sendMessage(`You have not unlocked this emote.`, true); + return; + } + } + + player.playAnimation(emote.animationId); + + if(emote.graphicId !== undefined) { + player.playGraphics({id: emote.graphicId, height: 0}); + } + } +}; + +export default new RunePlugin({ type: ActionType.BUTTON, widgetId: widgets.emotesTab, buttonIds, action }); diff --git a/src/plugins/buttons/player-setting-button-plugin.ts b/src/plugins/buttons/player-setting-button-plugin.ts index 3e08476fe..e131ebe03 100644 --- a/src/plugins/buttons/player-setting-button-plugin.ts +++ b/src/plugins/buttons/player-setting-button-plugin.ts @@ -1,16 +1,18 @@ -import { buttonAction } from '@server/world/mob/player/action/button-action'; +import { buttonAction } from '@server/world/actor/player/action/button-action'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; const buttonIds: number[] = [ - 152, 153, // walk/run - 930, 931, 932, 933, 934, // music volume - 941, 942, 943, 944, 945, // sound effect volume - 957, 958, // split private chat - 913, 914, // mouse buttons - 906, 908, 910, 912, // screen brightness - 915, 916, // chat effects - 12464, 12465, // accept aid - 150, 151, // auto retaliate + 0, // walk/run + 11, 12, 13, 14, 15, // music volume + 16, 17, 18, 19, 20, // sound effect volume + 29, 30, 31, 32, 33, // area effect volume + 2, // split private chat + 3, // mouse buttons + 7, 8, 9, 10, // screen brightness + 1, // chat effects + 4, // accept aid + 5, // house options ]; export const action: buttonAction = (details) => { @@ -18,4 +20,4 @@ export const action: buttonAction = (details) => { player.settingChanged(buttonId); }; -export default new RunePlugin({ type: ActionType.BUTTON, buttonIds, action }); +export default new RunePlugin({ type: ActionType.BUTTON, widgetId: widgets.settingsTab, buttonIds: buttonIds, action }); diff --git a/src/plugins/commands/client-config-command.ts b/src/plugins/commands/client-config-command.ts new file mode 100644 index 000000000..764a8e991 --- /dev/null +++ b/src/plugins/commands/client-config-command.ts @@ -0,0 +1,27 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const configId = args.configId as number; + const configValue = args.configValue as number; + + player.outgoingPackets.updateClientConfig(configId, configValue); +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'config', 'conf' ], + args: [ + { + name: 'configId', + type: 'number' + }, + { + name: 'configValue', + type: 'number' + } + ], + action +}); diff --git a/src/plugins/commands/current-position-command.ts b/src/plugins/commands/current-position-command.ts new file mode 100644 index 000000000..ed75a0c91 --- /dev/null +++ b/src/plugins/commands/current-position-command.ts @@ -0,0 +1,13 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player } = details; + player.sendLogMessage(`@[ ${player.position.x}, ${player.position.y}, ${player.position.level} ]`, details.isConsole); +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'pos', 'loc', 'position', 'location', 'coords', 'coordinates', 'mypos', 'myloc' ], + action +}); diff --git a/src/plugins/commands/emote-test-command.ts b/src/plugins/commands/emote-test-command.ts new file mode 100644 index 000000000..f7c245518 --- /dev/null +++ b/src/plugins/commands/emote-test-command.ts @@ -0,0 +1,39 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { lockEmote, unlockEmote, unlockEmotes } from '@server/plugins/buttons/player-emotes-plugin'; + +const action: commandAction = (details) => { + const { player, args } = details; + const emoteName = (args.emoteName as string).toUpperCase().replace(/_/g, ' '); + + const unlockedEmotes: string[] = player.savedMetadata.unlockedEmotes || []; + const index = unlockedEmotes.indexOf(emoteName); + + if(index !== -1) { + lockEmote(player, emoteName); + } else { + unlockEmote(player, emoteName); + } +}; + +const resetAction: commandAction = (details) => { + const { player } = details; + player.savedMetadata.unlockedEmotes = []; + unlockEmotes(player); +}; + +export default new RunePlugin([{ + type: ActionType.COMMAND, + commands: 'emote', + args: [ + { + name: 'emoteName', + type: 'string' + } + ], + action +}, { + type: ActionType.COMMAND, + commands: 'emotereset', + action: resetAction +}]); diff --git a/src/plugins/commands/exp-test-command.ts b/src/plugins/commands/exp-test-command.ts new file mode 100644 index 000000000..75c9c4471 --- /dev/null +++ b/src/plugins/commands/exp-test-command.ts @@ -0,0 +1,15 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { Skill } from '@server/world/actor/skills'; + +const action: commandAction = (details) => { + const { player } = details; + + player.skills.addExp(Skill.AGILITY, 1000); +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: 'exptest', + action +}); diff --git a/src/plugins/commands/give-item-command.ts b/src/plugins/commands/give-item-command.ts new file mode 100644 index 000000000..d7d360c44 --- /dev/null +++ b/src/plugins/commands/give-item-command.ts @@ -0,0 +1,65 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { cache } from '@server/game-server'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const inventorySlot = player.inventory.getFirstOpenSlot(); + + if(inventorySlot === -1) { + player.sendLogMessage(`You don't have enough free space to do that.`, details.isConsole); + return; + } + + const itemId: number = args.itemId as number; + let amount: number = args.amount as number; + + if(amount > 2000000000) { + throw new Error(`Unable to give more than 2,000,000,000.`); + } + + const itemDefinition = cache.itemDefinitions.get(itemId); + if(!itemDefinition) { + throw new Error(`Item ID ${itemId} not found!`); + } + + let actualAmount = 0; + if(itemDefinition.stackable) { + const item = { itemId, amount }; + player.giveItem(item); + actualAmount = amount; + } else { + if(amount > 28) { + amount = 28; + } + + for(let i = 0; i < amount; i++) { + if(player.giveItem({ itemId, amount: 1 })) { + actualAmount++; + } else { + break; + } + } + } + + player.sendLogMessage(`Added ${actualAmount}x ${itemDefinition.name} to inventory.`, details.isConsole); + +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'give', 'item', 'spawn' ], + args: [ + { + name: 'itemId', + type: 'number' + }, + { + name: 'amount', + type: 'number', + defaultValue: 1 + } + ], + action +}); diff --git a/src/plugins/commands/input-widget-command.ts b/src/plugins/commands/input-widget-command.ts new file mode 100644 index 000000000..a117669f0 --- /dev/null +++ b/src/plugins/commands/input-widget-command.ts @@ -0,0 +1,26 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const type: number = args.type as number; + + if(type === 1) { + player.outgoingPackets.showNumberInputDialogue(); + } else if(type === 2) { + player.outgoingPackets.showTextInputDialogue(); + } +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'input' ], + args: [ + { + name: 'type', + type: 'number' + } + ], + action +}); diff --git a/src/plugins/commands/item-selection-test-command.ts b/src/plugins/commands/item-selection-test-command.ts new file mode 100644 index 000000000..70ccde711 --- /dev/null +++ b/src/plugins/commands/item-selection-test-command.ts @@ -0,0 +1,29 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { itemSelectionAction } from '@server/world/actor/player/action/item-selection-action'; + +const action: commandAction = (details) => { + const { player } = details; + + itemSelectionAction(player, 'MAKING', [ + { itemId: 52, itemName: 'Arrow Shafts' }, + { itemId: 50, itemName: 'Shortbow' }, + { itemId: 48, itemName: 'Longbow' }, + { itemId: 9440, itemName: 'Crossbow Stock', offset: -4 } // `offset` and `zoom` are optional params for better item positioning if needed + ]).then(choice => { + if(!choice) { + return; + } + + player.sendMessage(`Player selected itemId ${choice.itemId} with amount ${choice.amount}`); + }).catch(error => { console.log('action cancelled'); }); // <- CATCH IS REQUIRED FOR ALL ITEM SELECTION ACTIONS! + // Always catch these, as the promise returned by `itemSelectionAction` will reject if actions have been cancelled! + // The console.log is not required, it's only here for testing purposes. +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: 'itemselection', + action, + cancelOtherActions: false +}); diff --git a/src/plugins/commands/move-command.ts b/src/plugins/commands/move-command.ts new file mode 100644 index 000000000..9faa212b6 --- /dev/null +++ b/src/plugins/commands/move-command.ts @@ -0,0 +1,34 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { Position } from '@server/world/position'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const x: number = args.x as number; + const y: number = args.y as number; + const level: number = args.level as number; + + player.teleport(new Position(x, y, level)); +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'move', 'goto', 'teleport', 'tele', 'moveto', 'setpos' ], + args: [ + { + name: 'x', + type: 'number' + }, + { + name: 'y', + type: 'number' + }, + { + name: 'level', + type: 'number', + defaultValue: 0 + } + ], + action +}); diff --git a/src/plugins/commands/new-dialogue-test-command.ts b/src/plugins/commands/new-dialogue-test-command.ts new file mode 100644 index 000000000..8444d1b09 --- /dev/null +++ b/src/plugins/commands/new-dialogue-test-command.ts @@ -0,0 +1,38 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { world } from '@server/game-server'; +import { dialogue, Emote, execute } from '@server/world/actor/dialogue'; + +const action: commandAction = (details) => { + const { player } = details; + const npc = world.npcList[0]; + + dialogue([ player, { npc, key: 'hans' } ], [ + hans => [ Emote.GENERIC, 'Hey how are you?' ], + () => ({ + 'Doing great!': [ + player => [ Emote.HAPPY, 'Doings great, how about yourself?' ], + hans => [ Emote.HAPPY, `Can't complain.` ] + ], + 'Eh, not bad.': [ + player => [ Emote.DROWZY, 'Eh, not bad.' ], + hans => [ Emote.GENERIC, 'I feel ya.' ] + ], + 'Not so good.': [ + player => [ Emote.SAD, 'Not so good, honestly.' ], + hans => [ Emote.WORRIED, 'What has you down?' ], + player => [ Emote.SAD, `Well, first it started this morning when my cat woke me up an hour early. After that, the little bastard just kept meowing and meowing at me...` ], + execute(() => { + player.setQuestStage('cooksAssistant', 'NOT_STARTED'); + player.sendMessage('Here ya go!'); + }), + hans => [ Emote.SAD, `Shit that sucks fam, I'm sorry.` ] + ] + }), + player => [ Emote.GENERIC, `See ya around.` ] + ]).then(() => { + // do something with dialogue result. + }); +}; + +export default new RunePlugin({ type: ActionType.COMMAND, commands: 'd', action }); diff --git a/src/plugins/commands/player-animation-command.ts b/src/plugins/commands/player-animation-command.ts new file mode 100644 index 000000000..e85219124 --- /dev/null +++ b/src/plugins/commands/player-animation-command.ts @@ -0,0 +1,21 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const animationId: number = args.animationId as number; + player.playAnimation(animationId); +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'anim', 'animation', 'playanim' ], + args: [ + { + name: 'animationId', + type: 'number' + } + ], + action +}); diff --git a/src/plugins/commands/player-graphics-command.ts b/src/plugins/commands/player-graphics-command.ts new file mode 100644 index 000000000..052edddf7 --- /dev/null +++ b/src/plugins/commands/player-graphics-command.ts @@ -0,0 +1,28 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const graphicsId: number = args.graphicsId as number; + const height: number = args.height as number; + + player.playGraphics({id: graphicsId, delay: 0, height: height}); +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'gfx', 'graphics'], + args: [ + { + name: 'graphicsId', + type: 'number' + }, + { + name: 'height', + type: 'number', + defaultValue: 120 + } + ], + action +}); diff --git a/src/plugins/commands/quest-reset-command.ts b/src/plugins/commands/quest-reset-command.ts new file mode 100644 index 000000000..af07fcf84 --- /dev/null +++ b/src/plugins/commands/quest-reset-command.ts @@ -0,0 +1,13 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { injectPlugins } from '@server/game-server'; +import { widgetScripts } from '@server/world/config/widget'; + +const action: commandAction = (details) => { + const { player } = details; + + player.quests.find(quest => quest.questId === 'cooksAssistant').stage = 'COLLECTING'; + player.outgoingPackets.updateClientConfig(widgetScripts.questPoints, 1000); +}; + +export default new RunePlugin({ type: ActionType.COMMAND, commands: 'resetquests', action }); diff --git a/src/plugins/commands/reload-plugins.ts b/src/plugins/commands/reload-plugins.ts new file mode 100644 index 000000000..9d949e809 --- /dev/null +++ b/src/plugins/commands/reload-plugins.ts @@ -0,0 +1,16 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { injectPlugins } from '@server/game-server'; + +const action: commandAction = (details) => { + const { player } = details; + + player.sendLogMessage('Reloading plugins...', details.isConsole); + + + injectPlugins() + .then(() => player.sendLogMessage('Plugins reloaded.', details.isConsole)) + .catch(() => player.sendLogMessage('Error reloading plugins.', details.isConsole)); +}; + +export default new RunePlugin({ type: ActionType.COMMAND, commands: 'plugins', action }); diff --git a/src/plugins/commands/sound-song-commands.ts b/src/plugins/commands/sound-song-commands.ts new file mode 100644 index 000000000..4222a8087 --- /dev/null +++ b/src/plugins/commands/sound-song-commands.ts @@ -0,0 +1,58 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const songAction: commandAction = (details) => { + const { player, args } = details; + player.outgoingPackets.playSong(args.songId as number); +}; + +const soundAction: commandAction = (details) => { + const { player, args } = details; + player.playSound(args.soundId as number, args.volume as number); +}; + +const quickSongAction: commandAction = (details) => { + const { player, args } = details; + player.outgoingPackets.playQuickSong(args.songId as number, args.prevSongId as number); +}; + +export default new RunePlugin([{ + type: ActionType.COMMAND, + commands: 'song', + args: [ + { + name: 'songId', + type: 'number' + } + ], + action: songAction +}, { + type: ActionType.COMMAND, + commands: 'sound', + args: [ + { + name: 'soundId', + type: 'number' + }, + { + name: 'volume', + type: 'number', + defaultValue: 10 + } + ], + action: soundAction +}, { + type: ActionType.COMMAND, + commands: 'quicksong', + args: [ + { + name: 'songId', + type: 'number' + }, + { + name: 'prevSongId', + type: 'number' + } + ], + action: quickSongAction +}]); diff --git a/src/plugins/commands/text-color-test-command.ts b/src/plugins/commands/text-color-test-command.ts new file mode 100644 index 000000000..9eed45442 --- /dev/null +++ b/src/plugins/commands/text-color-test-command.ts @@ -0,0 +1,9 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player } = details; + player.modifyWidget(239, { childId: 82, textColor: 0x0000ff }); +}; + +export default new RunePlugin({ type: ActionType.COMMAND, commands: 'textcolortest', action }); diff --git a/src/plugins/commands/tracking-commands.ts b/src/plugins/commands/tracking-commands.ts new file mode 100644 index 000000000..8bdef60b5 --- /dev/null +++ b/src/plugins/commands/tracking-commands.ts @@ -0,0 +1,42 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; +import { world } from '@server/game-server'; + +const quadtreeAction: commandAction = (details) => { + const { player } = details; + + const values = world.playerTree.colliding({ + x: player.position.x - 2, + y: player.position.y - 2, + width: 5, + height: 5 + }); + + console.log(values); +}; + +const trackedPlayersAction: commandAction = (details) => { + const { player } = details; + player.sendLogMessage(`Tracked players: ${player.trackedPlayers.length}`, details.isConsole); + +}; + +const trackedNpcsAction: commandAction = (details) => { + const { player } = details; + player.sendLogMessage(`Tracked npcs: ${player.trackedNpcs.length}`, details.isConsole); + +}; + +export default new RunePlugin([{ + type: ActionType.COMMAND, + commands: 'quadtree', + action: quadtreeAction +}, { + type: ActionType.COMMAND, + commands: 'trackedplayers', + action: trackedPlayersAction +}, { + type: ActionType.COMMAND, + commands: 'trackednpcs', + action: trackedNpcsAction +}]); diff --git a/src/plugins/commands/widget-commands.ts b/src/plugins/commands/widget-commands.ts new file mode 100644 index 000000000..84e4157d8 --- /dev/null +++ b/src/plugins/commands/widget-commands.ts @@ -0,0 +1,41 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { commandAction } from '@server/world/actor/player/action/input-command-action'; + +const action: commandAction = (details) => { + const { player, args } = details; + + const widgetId: number = args.widgetId as number; + const secondaryWidgetId: number = args.secondaryWidgetId as number; + + if(secondaryWidgetId === 1) { + player.activeWidget = { + type: 'SCREEN', + widgetId, + closeOnWalk: true + }; + } else { + player.activeWidget = { + type: 'SCREEN_AND_TAB', + widgetId, + secondaryWidgetId, + closeOnWalk: true + }; + } +}; + +export default new RunePlugin({ + type: ActionType.COMMAND, + commands: [ 'widget' ], + args: [ + { + name: 'widgetId', + type: 'number' + }, + { + name: 'secondaryWidgetId', + type: 'number', + defaultValue: 1 + } + ], + action +}); diff --git a/src/plugins/dialogue/dialogue-option-plugin.ts b/src/plugins/dialogue/dialogue-option-plugin.ts new file mode 100644 index 000000000..2a71053c6 --- /dev/null +++ b/src/plugins/dialogue/dialogue-option-plugin.ts @@ -0,0 +1,19 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgetAction } from '@server/world/actor/player/action/widget-action'; + +const dialogueIds = [ + 64, 65, 66, 67, 241, + 242, 243, 244, 228, 230, + 232, 234, + 210, 211, 212, 213, 214, +]; + +/** + * Handles a basic NPC/Player/Option/Text dialogue choice/action. + */ +export const action: widgetAction = (details) => { + const { player, childId } = details; + player.dialogueInteractionEvent.next(childId); +}; + +export default new RunePlugin({ type: ActionType.WIDGET_ACTION, widgetIds: dialogueIds, action, cancelActions: true }); diff --git a/src/plugins/dialogue/item-selection-plugin.ts b/src/plugins/dialogue/item-selection-plugin.ts new file mode 100644 index 000000000..0e4bb8d09 --- /dev/null +++ b/src/plugins/dialogue/item-selection-plugin.ts @@ -0,0 +1,12 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgetAction } from '@server/world/actor/player/action/widget-action'; + +/** + * Handles an item selection dialogue choice. + */ +export const action: widgetAction = (details) => { + const { player, childId } = details; + player.dialogueInteractionEvent.next(childId); +}; + +export default new RunePlugin({ type: ActionType.WIDGET_ACTION, widgetIds: [ 303, 304, 305, 306, 307, 309 ], action, cancelActions: false }); diff --git a/src/world/mob/player/action/equip-item-action.ts b/src/plugins/equipment/equip-item-plugin.ts similarity index 56% rename from src/world/mob/player/action/equip-item-action.ts rename to src/plugins/equipment/equip-item-plugin.ts index 3f66a6d0f..178691b67 100644 --- a/src/world/mob/player/action/equip-item-action.ts +++ b/src/plugins/equipment/equip-item-plugin.ts @@ -1,30 +1,57 @@ -import { Player } from '../player'; -import { world } from '@server/game-server'; -import { logger } from '@runejs/logger/dist/logger'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { updateBonusStrings } from '@server/plugins/equipment/equipment-stats-plugin'; import { EquipmentSlot, equipmentSlotIndex, ItemDetails, WeaponType } from '@server/world/config/item-data'; import { Item } from '@server/world/items/item'; -import { widgetIds } from '../widget'; +import { world } from '@server/game-server'; +import { Player } from '@server/world/actor/player/player'; import { ItemContainer } from '@server/world/items/item-container'; -export const equipItemAction = (player: Player, itemId: number, inventorySlot: number) => { - const itemToEquipData = world.itemData.get(itemId); +function unequipItem(player: Player, inventory: ItemContainer, equipment: ItemContainer, slot: EquipmentSlot): boolean { + const inventorySlot = inventory.getFirstOpenSlot(); - if(!itemToEquipData || !itemToEquipData.equipment || !itemToEquipData.equipment.slot) { - logger.warn(`Can not equip item ${itemId}/${itemToEquipData.name}`); - return; + if(inventorySlot === -1) { + player.sendMessage(`You don't have enough free space to do that.`); + return false; } + const itemInSlot = equipment.items[slot]; + + if(!itemInSlot) { + return true; + } + + equipment.remove(slot); + inventory.set(inventorySlot, itemInSlot); + return true; +} + +export const action: itemAction = (details) => { + const { player, itemId, itemSlot, itemDetails, widgetId } = details; + const inventory = player.inventory; const equipment = player.equipment; - const equipmentSlot = equipmentSlotIndex(itemToEquipData.equipment.slot); + const itemToEquip = getItemFromContainer(itemId, itemSlot, inventory); + + if(!itemToEquip) { + // The specified item was not found in the specified slot. + return; + } + + if(!itemDetails || !itemDetails.equipment || !itemDetails.equipment.slot) { + player.sendMessage(`Unable to equip item ${itemId}/${itemDetails.name}: Missing equipment data.`); + return; + } + + const equipmentSlot = equipmentSlotIndex(itemDetails.equipment.slot); - const itemToEquip: Item = inventory.items[inventorySlot]; const itemToUnequip: Item = equipment.items[equipmentSlot]; let shouldUnequipOffHand: boolean = false; let shouldUnequipMainHand: boolean = false; - if(itemToEquipData && itemToEquipData.equipment) { - if(itemToEquipData.equipment.weaponType === WeaponType.TWO_HANDED) { + if(itemDetails && itemDetails.equipment) { + if(itemDetails.equipment.weaponType === WeaponType.TWO_HANDED) { shouldUnequipOffHand = true; } @@ -47,13 +74,13 @@ export const equipItemAction = (player: Player, itemId: number, inventorySlot: n } equipment.remove(equipmentSlot, false); - inventory.remove(inventorySlot, false); + inventory.remove(itemSlot, false); equipment.set(equipmentSlot, itemToEquip); - inventory.set(inventorySlot, itemToUnequip); + inventory.set(itemSlot, itemToUnequip); } else { equipment.set(equipmentSlot, itemToEquip); - inventory.remove(inventorySlot); + inventory.remove(itemSlot); if(shouldUnequipOffHand) { unequipItem(player, inventory, equipment, EquipmentSlot.OFF_HAND); @@ -64,28 +91,24 @@ export const equipItemAction = (player: Player, itemId: number, inventorySlot: n } } - // @TODO change packets to only update modified container slots - player.packetSender.sendUpdateAllWidgetItems(widgetIds.inventory, inventory); - player.packetSender.sendUpdateAllWidgetItems(widgetIds.equipment, equipment); player.updateBonuses(); - player.updateFlags.appearanceUpdateRequired = true; -}; -function unequipItem(player: Player, inventory: ItemContainer, equipment: ItemContainer, slot: EquipmentSlot): boolean { - const inventorySlot = inventory.getFirstOpenSlot(); + // @TODO change packets to only update modified container slots + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, inventory); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.equipment, equipment); - if(inventorySlot === -1) { - player.packetSender.chatboxMessage(`You don't have enough free space to do that.`); - return false; + if(player.hasWidgetOpen(widgets.equipmentStats.widgetId)) { + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.equipmentStats, equipment); + updateBonusStrings(player); } - const itemInSlot = equipment.items[slot]; - - if(!itemInSlot) { - return true; - } + player.updateFlags.appearanceUpdateRequired = true; +}; - equipment.remove(slot); - inventory.set(inventorySlot, itemInSlot); - return true; -} +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: widgets.inventory, + options: 'equip', + action, + cancelOtherActions: false +}); diff --git a/src/plugins/equipment/equipment-stats-plugin.ts b/src/plugins/equipment/equipment-stats-plugin.ts new file mode 100644 index 000000000..c039db5e0 --- /dev/null +++ b/src/plugins/equipment/equipment-stats-plugin.ts @@ -0,0 +1,42 @@ +import { buttonAction } from '@server/world/actor/player/action/button-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; +import { Player } from '@server/world/actor/player/player'; + +export function updateBonusStrings(player: Player): void { + [ + { id: 108, text: 'Stab', value: player.bonuses.offencive.stab }, + { id: 109, text: 'Slash', value: player.bonuses.offencive.slash }, + { id: 110, text: 'Crush', value: player.bonuses.offencive.crush }, + { id: 111, text: 'Magic', value: player.bonuses.offencive.magic }, + { id: 112, text: 'Range', value: player.bonuses.offencive.ranged }, + { id: 113, text: 'Stab', value: player.bonuses.defencive.stab }, + { id: 114, text: 'Slash', value: player.bonuses.defencive.slash }, + { id: 115, text: 'Crush', value: player.bonuses.defencive.crush }, + { id: 116, text: 'Magic', value: player.bonuses.defencive.magic }, + { id: 117, text: 'Range', value: player.bonuses.defencive.ranged }, + { id: 119, text: 'Strength', value: player.bonuses.skill.strength }, + { id: 120, text: 'Prayer', value: player.bonuses.skill.prayer }, + ].forEach(bonus => player.modifyWidget(widgets.equipmentStats.widgetId, { childId: bonus.id, + text: `${bonus.text}: ${bonus.value > 0 ? `+${bonus.value}` : bonus.value}` })); +} + +export const action: buttonAction = (details) => { + const { player } = details; + + player.updateBonuses(); + + updateBonusStrings(player); + + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.equipmentStats, player.equipment); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, player.inventory); + + player.activeWidget = { + widgetId: widgets.equipmentStats.widgetId, + secondaryWidgetId: widgets.inventory.widgetId, + type: 'SCREEN_AND_TAB', + closeOnWalk: true + }; +}; + +export default new RunePlugin({ type: ActionType.BUTTON, widgetId: widgets.equipment.widgetId, buttonIds: 24, action }); diff --git a/src/plugins/equipment/unequip-item-plugin.ts b/src/plugins/equipment/unequip-item-plugin.ts new file mode 100644 index 000000000..be94acfee --- /dev/null +++ b/src/plugins/equipment/unequip-item-plugin.ts @@ -0,0 +1,50 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { updateBonusStrings } from '@server/plugins/equipment/equipment-stats-plugin'; + +export const action: itemAction = (details) => { + const { player, itemId, itemSlot, widgetId } = details; + + const equipment = player.equipment; + const item = getItemFromContainer(itemId, itemSlot, equipment); + + if(!item) { + // The specified item was not found in the specified slot. + return; + } + + const inventory = player.inventory; + const inventorySlot = inventory.getFirstOpenSlot(); + + if(inventorySlot === -1) { + player.sendMessage(`You don't have enough free space to do that.`); + return; + } + + equipment.remove(itemSlot); + inventory.set(inventorySlot, item); + + player.updateBonuses(); + + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, inventorySlot, item); + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.equipment, itemSlot, null); + + if(widgetId === widgets.equipmentStats.widgetId) { + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.equipmentStats, itemSlot, null); + updateBonusStrings(player); + } + + player.updateFlags.appearanceUpdateRequired = true; +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: [ + widgets.equipment, + widgets.equipmentStats + ], + options: 'remove', + action, + cancelOtherActions: false +}); diff --git a/src/plugins/items/buckets/empty-container-plugin.ts b/src/plugins/items/buckets/empty-container-plugin.ts new file mode 100644 index 000000000..34b4beaea --- /dev/null +++ b/src/plugins/items/buckets/empty-container-plugin.ts @@ -0,0 +1,40 @@ +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { widgets } from '@server/world/config/widget'; +import { soundIds } from '@server/world/config/sound-ids'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { itemIds } from '@server/world/config/item-ids'; + +export const action: itemAction = (details) => { + const { player, itemId, itemSlot } = details; + + const inventory = player.inventory; + const item = getItemFromContainer(itemId, itemSlot, inventory); + + if(!item) { + // The specified item was not found in the specified slot. + return; + } + + inventory.remove(itemSlot); + player.playSound(soundIds.emptyBucket, 5); + switch (itemId) { + case itemIds.jugOfWater: + player.giveItem(itemIds.jug); + break; + default: + player.giveItem(itemIds.bucket); + break; + } + + // @TODO only update necessary slots + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, inventory); +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: widgets.inventory, + options: 'empty', + itemIds: [itemIds.bucketOfMilk, itemIds.bucketOfWater, itemIds.jugOfWater], + action, + cancelOtherActions: false +}); diff --git a/src/plugins/items/buckets/fill-container-plugin.ts b/src/plugins/items/buckets/fill-container-plugin.ts new file mode 100644 index 000000000..19298358a --- /dev/null +++ b/src/plugins/items/buckets/fill-container-plugin.ts @@ -0,0 +1,43 @@ +import { itemOnObjectAction } from '@server/world/actor/player/action/item-on-object-action'; +import { cache } from '@server/game-server'; +import { itemIds } from '@server/world/config/item-ids'; +import { animationIds } from '@server/world/config/animation-ids'; +import { soundIds } from '@server/world/config/sound-ids'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; + +const FountainIds: number[] = [879]; +const SinkIds: number[] = [14878, 873]; +const WellIds: number[] = [878]; +export const action: itemOnObjectAction = (details) => { + const {player, objectDefinition, item} = details; + const itemDef = cache.itemDefinitions.get(item.itemId); + if (item.itemId !== itemIds.bucket && WellIds.indexOf(objectDefinition.id) > -1) { + player.sendMessage(`If I drop my ${itemDef.name.toLowerCase()} down there, I don't think I'm likely to get it back.`); + return; + } + + player.playAnimation(animationIds.fillContainerWithWater); + player.playSound(soundIds.fillContainerWithWater, 7); + player.removeFirstItem(item.itemId); + switch (item.itemId) { + case itemIds.bucket: + player.giveItem(itemIds.bucketOfWater); + break; + case itemIds.jug: + player.giveItem(itemIds.jugOfWater); + break; + + + } + + player.sendMessage(`You fill the ${itemDef.name.toLowerCase()} from the ${objectDefinition.name.toLowerCase()}.`); + +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ON_OBJECT_ACTION, + objectIds: [...FountainIds, ...WellIds, ...SinkIds], + itemIds: [itemIds.bucket, itemIds.jug], + walkTo: true, + action +}); diff --git a/src/plugins/items/drop-item-plugin.ts b/src/plugins/items/drop-item-plugin.ts new file mode 100644 index 000000000..7287ee09c --- /dev/null +++ b/src/plugins/items/drop-item-plugin.ts @@ -0,0 +1,30 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { world } from '@server/game-server'; +import { soundIds } from '@server/world/config/sound-ids'; + +export const action: itemAction = (details) => { + const { player, itemId, itemSlot } = details; + + const inventory = player.inventory; + const item = getItemFromContainer(itemId, itemSlot, inventory); + + if(!item) { + // The specified item was not found in the specified slot. + return; + } + + inventory.remove(itemSlot); + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, itemSlot, null); + player.playSound(soundIds.dropItem, 5); + world.spawnWorldItem(item, player.position, player, 300); +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: widgets.inventory, + options: 'drop', + action, + cancelOtherActions: false +}); diff --git a/src/plugins/items/pickup-item-plugin.ts b/src/plugins/items/pickup-item-plugin.ts new file mode 100644 index 000000000..55f0a56dd --- /dev/null +++ b/src/plugins/items/pickup-item-plugin.ts @@ -0,0 +1,54 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { worldItemAction } from '@server/world/actor/player/action/world-item-action'; +import { world } from '../../game-server'; +import { Item } from '../../world/items/item'; +import { widgets } from '../../world/config/widget'; +import { soundIds } from '@server/world/config/sound-ids'; + +export const action: worldItemAction = (details) => { + const { player, worldItem } = details; + + const inventory = player.inventory; + let slot = -1; + const itemData = world.itemData.get(worldItem.itemId); + let amount = worldItem.amount; + + if(itemData.stackable) { + const existingItemIndex = inventory.findIndex(worldItem.itemId); + if(existingItemIndex !== -1) { + const existingItem = inventory.items[existingItemIndex]; + if(existingItem.amount + worldItem.amount < 2147483647) { + existingItem.amount += worldItem.amount; + amount += existingItem.amount; + slot = existingItemIndex; + } + } + } + + if(slot === -1) { + slot = inventory.getFirstOpenSlot(); + } + + if(slot === -1) { + player.sendMessage(`You don't have enough free space to do that.`); + return; + } + + world.removeWorldItem(worldItem); + + const item: Item = { + itemId: worldItem.itemId, + amount + }; + + inventory.add(item); + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, slot, item); + player.playSound(soundIds.pickupItem, 3); +}; + +export default new RunePlugin({ + type: ActionType.WORLD_ITEM_ACTION, + options: 'pick-up', + action, + walkTo: true +}); diff --git a/src/plugins/items/pots/empty-pot-plugin.ts b/src/plugins/items/pots/empty-pot-plugin.ts new file mode 100644 index 000000000..b8c1e6f22 --- /dev/null +++ b/src/plugins/items/pots/empty-pot-plugin.ts @@ -0,0 +1,30 @@ +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { widgets } from '@server/world/config/widget'; +import { soundIds } from '@server/world/config/sound-ids'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { itemIds } from '@server/world/config/item-ids'; + +export const action: itemAction = (details) => { + const {player, itemId, itemSlot} = details; + + const inventory = player.inventory; + const item = getItemFromContainer(itemId, itemSlot, inventory); + + if (!item) { + // The specified item was not found in the specified slot. + return; + } + + inventory.remove(itemSlot); + player.playSound(soundIds.potContentModified, 5); + player.giveItem(itemIds.pot); +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: widgets.inventory, + options: 'empty', + itemIds: [itemIds.potOfFlour], + action, + cancelOtherActions: false +}); diff --git a/src/plugins/items/shopping/buy-from-shop-plugin.ts b/src/plugins/items/shopping/buy-from-shop-plugin.ts new file mode 100644 index 000000000..82918900a --- /dev/null +++ b/src/plugins/items/shopping/buy-from-shop-plugin.ts @@ -0,0 +1,120 @@ +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { widgets } from '@server/world/config/widget'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { Shop, shopItemContainer } from '@server/world/config/shops'; +import { world } from '@server/game-server'; +import { Item } from '@server/world/items/item'; +import { ItemContainer } from '@server/world/items/item-container'; +import { itemIds } from '@server/world/config/item-ids'; + +function removeCoins(inventory: ItemContainer, coinsIndex: number, cost: number): void { + const coins = inventory.items[coinsIndex]; + const amountAfterPurchase = coins.amount - cost; + inventory.set(coinsIndex, { itemId: itemIds.coins, amount: amountAfterPurchase }); +} + +export const action: itemAction = (details) => { + const { player, itemId, itemSlot, widgetId, containerId, option } = details; + + if(!player.activeWidget || player.activeWidget.widgetId !== widgetId) { + return; + } + + const openedShop: Shop = player.metadata['lastOpenedShop']; + if(!openedShop) { + return; + } + + const shopContainer = shopItemContainer(openedShop); + const shopItem = getItemFromContainer(itemId, itemSlot, shopContainer); + + if(!shopItem) { + // The specified item was not found in the specified slot. + return; + } + + if(shopItem.amount <= 0) { + // Out of stock + return; + } + + const buyAmounts = { + 'buy-1': 1, + 'buy-5': 5, + 'buy-10': 10 + }; + let buyAmount = buyAmounts[option]; + if(shopItem.amount < buyAmount) { + buyAmount = shopItem.amount; + } + + const buyItem = world.itemData.get(itemId); + const buyItemValue = buyItem.value || 0; + let buyCost = buyAmount * buyItemValue; + const coinsIndex = player.hasCoins(buyCost); + + if(coinsIndex === -1) { + player.sendMessage(`You don't have enough coins.`); + return; + } + + const inventory = player.inventory; + + if(buyItem.stackable) { + const inventoryStackSlot = inventory.items.findIndex(item => itemId === itemId); + + if(inventoryStackSlot === -1) { + if(inventory.getFirstOpenSlot() === -1) { + player.sendMessage(`You don't have enough space in your inventory.`); + return; + } + } else { + const inventoryItem = inventory.items[inventoryStackSlot]; + if(inventoryItem.amount + buyAmount >= 2147483647) { + player.sendMessage(`You don't have enough space in your inventory.`); + return; + } + + shopContainer.set(itemSlot, { itemId, amount: shopItem.amount - buyAmount }); + openedShop.items[itemSlot].amountInStock -= buyAmount; + removeCoins(inventory, coinsIndex, buyCost); + + const item: Item = { + itemId, amount: inventoryItem.amount + buyAmount + }; + + inventory.set(inventoryStackSlot, item); + } + } else { + let bought = 0; + + for(let i = 0; i < buyAmount; i++) { + if(inventory.add({ itemId, amount: 1 }) !== null) { + bought++; + } else { + break; + } + } + + if(bought !== buyAmount) { + player.sendMessage(`You don't have enough space in your inventory.`); + } + + shopContainer.set(itemSlot, { itemId, amount: shopItem.amount - bought }); + openedShop.items[itemSlot].amountInStock -= bought; + buyCost = bought * buyItemValue; + removeCoins(inventory, coinsIndex, buyCost); + } + + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.shop, itemSlot, shopContainer.items[itemSlot]); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.shopPlayerInventory, inventory); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, inventory); +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: widgets.shop, + options: [ 'buy-1', 'buy-5', 'buy-10' ], + action, + cancelOtherActions: false +}); diff --git a/src/plugins/items/shopping/item-value-plugin.ts b/src/plugins/items/shopping/item-value-plugin.ts new file mode 100644 index 000000000..97fca97e5 --- /dev/null +++ b/src/plugins/items/shopping/item-value-plugin.ts @@ -0,0 +1,38 @@ +import { itemAction } from '@server/world/actor/player/action/item-action'; +import { widgets } from '@server/world/config/widget'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { world } from '@server/game-server'; + +export const shopSellValueAction: itemAction = (details) => { + const { player, itemId } = details; + + const item = world.itemData.get(itemId); + + if(!item) { + return; + } + + const itemValue = item.value || 1; + + player.sendMessage(`${item.name}: currently costs ${itemValue} coins.`); +}; + +export const shopPurchaseValueAction: itemAction = (details) => { + const { player } = details; + + player.sendMessage(`Shop purchase value is TBD`); +}; + +export default new RunePlugin([{ + type: ActionType.ITEM_ACTION, + widgets: widgets.shop, + options: 'value', + action: shopSellValueAction, + cancelOtherActions: false +}, { + type: ActionType.ITEM_ACTION, + widgets: widgets.shopPlayerInventory, + options: 'value', + action: shopPurchaseValueAction, + cancelOtherActions: false +}]); diff --git a/src/plugins/items/shopping/sell-to-shop.ts b/src/plugins/items/shopping/sell-to-shop.ts new file mode 100644 index 000000000..f30f49192 --- /dev/null +++ b/src/plugins/items/shopping/sell-to-shop.ts @@ -0,0 +1,97 @@ +import { getItemFromContainer, itemAction } from '@server/world/actor/player/action/item-action'; +import { widgets } from '@server/world/config/widget'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { Shop, shopItemContainer } from '@server/world/config/shops'; +import { world } from '@server/game-server'; +import { itemIds } from '@server/world/config/item-ids'; + +export const action: itemAction = (details) => { + const { player, itemId, itemSlot, widgetId, containerId, option } = details; + + if(!player.activeWidget || player.activeWidget.widgetId !== widgets.shop.widgetId) { + return; + } + + const openedShop: Shop = player.metadata['lastOpenedShop']; + if(!openedShop) { + return; + } + + const inventory = player.inventory; + const inventoryItem = getItemFromContainer(itemId, itemSlot, inventory); + + if(!inventoryItem) { + // The specified item was not found in the specified slot. + return; + } + + const sellAmounts = { + 'sell-1': 1, + 'sell-5': 5, + 'sell-10': 10 + }; + let sellAmount = sellAmounts[option]; + const itemDetails = world.itemData.get(itemId); + const shopContainer = shopItemContainer(openedShop); + const shopSpaces = shopContainer.items.filter(item => item === null); + + const shopItemIndex = shopContainer.items.findIndex(item => item !== null && item.itemId === itemId); + if(shopItemIndex === -1 && shopSpaces.length === 0) { + player.sendMessage(`There isn't enough space in the shop.`); + return; + } + + const shopItem = shopContainer.items[shopItemIndex]; + + if(itemDetails.stackable) { + if(inventoryItem.amount < sellAmount) { + inventory.remove(itemSlot); + sellAmount = inventoryItem.amount; + } else { + 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; + } + + for(let i = 0; i < sellAmount; i++) { + inventory.remove(foundItems[i]); + } + } + + const itemValue = itemDetails.value || 0; + + if(!shopItem) { + shopContainer.set(shopContainer.getFirstOpenSlot(), { itemId, amount: sellAmount }); + openedShop.items.push({ amountInStock: sellAmount, id: itemId, name: itemDetails.name, price: itemValue }); + } else { + shopItem.amount += sellAmount; + openedShop.items[shopItemIndex].amountInStock += sellAmount; + } + + const sellPrice = sellAmount * itemValue; // @TODO player inventory item devaluation/saturation + if(sellPrice > 0) { + let coinsIndex = player.hasCoins(1); + + if(coinsIndex === -1) { + coinsIndex = inventory.getFirstOpenSlot(); + inventory.set(coinsIndex, {itemId: itemIds.coins, amount: sellPrice}); + } else { + inventory.items[coinsIndex].amount += sellPrice; + } + } + + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.shop, shopContainer); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.shopPlayerInventory, inventory); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, inventory); +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ACTION, + widgets: widgets.shopPlayerInventory, + options: [ 'sell-1', 'sell-5', 'sell-10' ], + action, + cancelOtherActions: false +}); diff --git a/src/plugins/npcs/al-kharid/dommik-crafting-shop-plugin.ts b/src/plugins/npcs/al-kharid/dommik-crafting-shop-plugin.ts new file mode 100644 index 000000000..d45a993ed --- /dev/null +++ b/src/plugins/npcs/al-kharid/dommik-crafting-shop-plugin.ts @@ -0,0 +1,39 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { openShop } from '@server/world/actor/player/action/shop-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; +import { dialogueAction, DialogueEmote } from '@server/world/actor/player/action/dialogue-action'; + +const tradeAction: npcAction = (details) => { + openShop(details.player, 'DOMMIK_CRAFTING_STORE'); +}; + +const talkToAction : npcAction = (details) => { + const { player, npc } = details; + dialogueAction(player) + .then(d => d.npc(npc, DialogueEmote.CALM_TALK_1, ['Would you like to buy some crafting equipment?'])) + .then(d => d.options('Would you like to buy some crafting equipment?', ['No thanks. I\'ve got all the Crafting equipment I need.', 'Let\'s see what you\'ve got, then.'])) + .then(async d => { + switch (d.action) { + case 1: + return d.player(DialogueEmote.JOYFUL, [ 'No thanks; I\'ve got all the Crafting equipment I need.' ]) + .then(d => d.npc(npcIds.dommik, DialogueEmote.CALM_TALK_2, ['Okay. Fare well on your travels.'])) + .then(d => { + d.close(); + return d; + }); + case 2: + return d.player(DialogueEmote.CALM_TALK_1, ['No, thank you.']) + .then(d => { + tradeAction(details); + return d; + }); + + } + }); +}; + +export default new RunePlugin([ + { type: ActionType.NPC_ACTION, npcIds: npcIds.dommik, options: 'trade', walkTo: true, action: tradeAction }, + { type: ActionType.NPC_ACTION, npcIds: npcIds.dommik, options: 'talk-to', walkTo: true, action: talkToAction} +]); diff --git a/src/plugins/npcs/al-kharid/gem-trader-plugin.ts b/src/plugins/npcs/al-kharid/gem-trader-plugin.ts new file mode 100644 index 000000000..5ca7438e0 --- /dev/null +++ b/src/plugins/npcs/al-kharid/gem-trader-plugin.ts @@ -0,0 +1,39 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { openShop } from '@server/world/actor/player/action/shop-action'; +import { dialogueAction, DialogueEmote } from '@server/world/actor/player/action/dialogue-action'; +import { npcIds } from '@server/world/config/npc-ids'; + +const tradeAction : npcAction = (details) => { + openShop(details.player, 'ALKHARID_GEM_TRADER'); +}; + +const talkToAction : npcAction = (details) => { + const {player, npc} = details; + dialogueAction(player) + .then(d => d.npc(npc, DialogueEmote.CALM_TALK_1, [ 'Good day to you, traveller.', 'Would you be interested in buying some gems?'])) + .then(d => d.options('Would you be interested in buying some gems?', ['Yes, please.', 'No, thank you.'])) + .then(async d => { + switch (d.action) { + case 1: + return d.player(DialogueEmote.JOYFUL, [ 'Yes, please!' ]) + .then(d => { + tradeAction(details); + return d; + }); + case 2: + return d.player(DialogueEmote.CALM_TALK_1, ['No, thank you.']) + .then(d => d.npc(npc, DialogueEmote.ANNOYED, ['Eh, suit yourself.'])) + .then(d => { + d.close(); + return d; + }); + + } + }); +}; + +export default new RunePlugin([ + {type: ActionType.NPC_ACTION, npcIds: npcIds.gemTrader, options: 'trade', walkTo: true, action: tradeAction}, + {type: ActionType.NPC_ACTION, npcIds: npcIds.gemTrader, options: 'talk-to', walkTo: true, action: talkToAction} +]); diff --git a/src/plugins/npcs/al-kharid/louie-armoured-legs-plugin.ts b/src/plugins/npcs/al-kharid/louie-armoured-legs-plugin.ts new file mode 100644 index 000000000..105ce7112 --- /dev/null +++ b/src/plugins/npcs/al-kharid/louie-armoured-legs-plugin.ts @@ -0,0 +1,16 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { openShop } from '@server/world/actor/player/action/shop-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; + +const tradeAction : npcAction = (details) => { + openShop(details.player, 'LOUIES_ARMOURED_LEGS_BAZAR'); +}; + +export default new RunePlugin({ + type: ActionType.NPC_ACTION, + npcIds: npcIds.louieLegs, + options: 'trade', + walkTo: true, + action: tradeAction +}); \ No newline at end of file diff --git a/src/plugins/npcs/al-kharid/ranael-super-skirt-plugin.ts b/src/plugins/npcs/al-kharid/ranael-super-skirt-plugin.ts new file mode 100644 index 000000000..d50a36dad --- /dev/null +++ b/src/plugins/npcs/al-kharid/ranael-super-skirt-plugin.ts @@ -0,0 +1,16 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { openShop } from '@server/world/actor/player/action/shop-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; + +const tradeAction : npcAction = (details) => { + openShop(details.player, 'RANAELS_SUPER_SKIRT_STORE'); +}; + +export default new RunePlugin({ + type: ActionType.NPC_ACTION, + npcIds: npcIds.ranael, + walkTo: true, + options: 'trade', + action: tradeAction, +}); \ No newline at end of file diff --git a/src/plugins/npcs/bob-plugin.ts b/src/plugins/npcs/bob-plugin.ts deleted file mode 100644 index 7c99f9e9a..000000000 --- a/src/plugins/npcs/bob-plugin.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { npcAction } from '@server/world/mob/player/action/npc-action'; -import { openShop } from '@server/world/mob/player/action/shop-action'; -import { ActionType, RunePlugin } from '@server/plugins/plugin'; - -const action: npcAction = (details) => { - const { player, npc } = details; - openShop(details.player, 'BOBS_AXES'); -}; - -export default new RunePlugin({ type: ActionType.NPC_ACTION, npcIds: 519, options: 'trade', walkTo: true, action }); diff --git a/src/plugins/npcs/hans-plugin.ts b/src/plugins/npcs/hans-plugin.ts deleted file mode 100644 index aaf7275c8..000000000 --- a/src/plugins/npcs/hans-plugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { npcAction } from '@server/world/mob/player/action/npc-action'; -import { dialogueAction, DialogueEmote } from '@server/world/mob/player/action/dialogue-action'; -import { ActionType, RunePlugin } from '@server/plugins/plugin'; - -const action: npcAction = (details) => { - const { player, npc } = details; - - dialogueAction(player) - .then(d => d.npc(npc, DialogueEmote.CALM_TALK_1, [ 'Welcome to RuneScape!' ])) - .then(d => d.npc(npc, DialogueEmote.CALM_TALK_2, [ 'How do you feel about Rune.JS so far?', 'Please take a moment to let us know what you think!' ])) - .then(d => d.options('Thoughts?', [ 'Love it!', 'Kind of cool.', `Eh, I don't know...`, `Not my cup of tea, honestly.`, `It's literally the worst.` ])) - .then(d => { - switch(d.action) { - case 1: - return d.player(DialogueEmote.JOYFUL, [ 'Loving it so far, thanks for asking!' ]) - .then(d => d.npc(npc, DialogueEmote.JOYFUL, [ `You're very welcome! Glad to hear it.` ])); - case 2: - return d.player(DialogueEmote.DEFAULT, [ `It's kind of cool, I guess.`, 'Bit of a weird gimmick.' ]) - .then(d => d.npc(npc, DialogueEmote.DEFAULT, [ `Please let us know if you have any suggestions.` ])); - case 3: - return d.player(DialogueEmote.NOT_INTERESTED, [ `Ehhh... I don't know...` ]) - .then(d => d.npc(npc, DialogueEmote.CALM_TALK_1, [ `We're always open to feedback or`, `Pull Requests anytime you like.` ])) - .then(d => d.player(DialogueEmote.CALM_TALK_1, [ `I'll keep that in mind, thanks.` ])); - case 4: - return d.player(DialogueEmote.CALM_TALK_2, [ `Not really my cup of tea, but keep at it.` ]) - .then(d => d.npc(npc, DialogueEmote.JOYFUL, [ `Thanks for the support!` ])); - case 5: - return d.player(DialogueEmote.ANGRY_1, [ `Literally the worst thing I've ever seen.`, 'You disgust me on a personal level.' ]) - .then(d => d.npc(npc, DialogueEmote.SAD_3, [ `I-is that so?...`, `Well I'm... I'm sorry to hear that.` ])) - .then(d => { - d.action = 1; - return d; - }); - } - }) - .then(d => { - d.close(); - - npc.clearFaceMob(); - player.clearFaceMob(); - - if(d.action === 1) { - npc.updateFlags.animation = { id: 860 }; - npc.updateFlags.addChatMessage({ message: 'Jerk!' }); - player.packetSender.chatboxMessage('Hans wanders off rather dejectedly.'); - } else { - player.packetSender.chatboxMessage('Hans wanders off aimlessly through the courtyard.'); - } - }); -}; - -export default new RunePlugin({ type: ActionType.NPC_ACTION, npcIds: 0, options: 'talk-to', walkTo: true, action }); diff --git a/src/plugins/npcs/lumbridge/bob-plugin.ts b/src/plugins/npcs/lumbridge/bob-plugin.ts new file mode 100644 index 000000000..144fa2f4e --- /dev/null +++ b/src/plugins/npcs/lumbridge/bob-plugin.ts @@ -0,0 +1,10 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { openShop } from '@server/world/actor/player/action/shop-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; + +const tradeAction: npcAction = (details) => { + openShop(details.player, 'BOBS_AXES'); +}; + +export default new RunePlugin({ type: ActionType.NPC_ACTION, npcIds: npcIds.lumbridgeBob, options: 'trade', walkTo: true, action: tradeAction }); diff --git a/src/plugins/npcs/lumbridge/hans-plugin.ts b/src/plugins/npcs/lumbridge/hans-plugin.ts new file mode 100644 index 000000000..5c0b9b567 --- /dev/null +++ b/src/plugins/npcs/lumbridge/hans-plugin.ts @@ -0,0 +1,54 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; +import { animationIds } from '@server/world/config/animation-ids'; +import { dialogue, Emote, execute, goto } from '@server/world/actor/dialogue'; + +const action: npcAction = (details) => { + const { player, npc } = details; + + let sadEnding = false; + + dialogue([ player, { npc, key: 'hans' }], [ + hans => [ Emote.GENERIC, `Welcome to RuneScape!` ], + (hans, tag_Hans_Question) => [ Emote.HAPPY, `How do you feel about Rune.JS so far?\n` + + `Please take a moment to let us know what you think!` ], + options => ([ + `Love it!`, [ + player => [ Emote.HAPPY, `Loving it so far, thanks for asking!` ], + hans => [ Emote.HAPPY, `You're very welcome! Glad to hear it.` ] + ], + `Kind of cool.`, [ + player => [ Emote.GENERIC, `It's kind of cool, I guess. Bit of a weird gimmick.` ], + hans => [ Emote.HAPPY, `Please let us know if you have any suggestions.` ] + ], + `Not my cup of tea, honestly.`, [ + player => [ Emote.SKEPTICAL, `Not really my cup of tea, but keep at it.` ], + hans => [ Emote.GENERIC, `Thanks for the support!` ] + ], + `It's literally the worst.`, [ + player => [ Emote.ANGRY, `Literally the worst thing I've ever seen. You disgust me on a personal level.` ], + hans => [ Emote.SAD, `I-is that so?... Well I'm... I'm sorry to hear that.` ], + execute(() => sadEnding = true) + ], + `What?`, [ + player => [ Emote.DROWZY, `What?...` ], + goto('tag_Hans_Question') + ] + ]), + execute(() => { + npc.clearFaceActor(); + player.clearFaceActor(); + + if(sadEnding) { + npc.playAnimation(animationIds.cry); + npc.say(`Jerk!`); + player.sendMessage(`Hans wanders off rather dejectedly.`); + } else { + player.sendMessage(`Hans wanders off aimlessly through the courtyard.`); + } + }) + ]); +}; + +export default new RunePlugin({ type: ActionType.NPC_ACTION, npcIds: npcIds.hans, options: 'talk-to', walkTo: true, action }); diff --git a/src/plugins/npcs/lumbridge/lumbridge-farm-helpers-plugin.ts b/src/plugins/npcs/lumbridge/lumbridge-farm-helpers-plugin.ts new file mode 100644 index 000000000..962f695e7 --- /dev/null +++ b/src/plugins/npcs/lumbridge/lumbridge-farm-helpers-plugin.ts @@ -0,0 +1,100 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; +import { dialogue, Emote, goto } from '@server/world/actor/dialogue'; + +const millieDialogue: npcAction = (details) => { + const { player, npc } = details; + + dialogue([ player, { npc, key: 'millie' }], [ + millie => [ Emote.GENERIC, `Hello Adventurer. Welcome to Mill Lane Mill. Can I help you?` ], + options => [ + `Who are you?`, [ + player => [ Emote.WONDERING, `Who are you?` ], + millie => [ Emote.HAPPY, `I'm Miss Millicent Miller the Miller of Mill Lane Mill. ` + + `Our family have been milling flour for generations.` ], + player => [ Emote.GENERIC, `It's a good business to be in. People will always need flour.` ], + goto('tag_Mill_Flour') + ], + `What is this place?`, [ + player => [ Emote.WONDERING, `What is this place?` ], + millie => [ Emote.HAPPY, `This is Mill Lane Mill. Millers of the finest flour in Gielinor, ` + + `and home to the Miller family for many generations.` ], + millie => [ Emote.GENERIC, `We take grain from the field nearby and mill into flour.` ], + goto('tag_Mill_Flour') + ], + `How do I mill flour?`, [ + (player, tag_Mill_Flour) => [ Emote.WONDERING, `How do I mill flour?` ], + millie => [ Emote.GENERIC, `Making flour is pretty easy. First of all you need to get some grain. ` + + `You can pick some from wheat fields. There is one just outside the Mill, but there are ` + + `many others scattered across Gielinor.` ], + millie => [ Emote.GENERIC, `Feel free to pick wheat from our field! There always seems to be plenty ` + + `of wheat there.` ], + player => [ Emote.WONDERING, `Then I bring my wheat here?` ], + millie => [ Emote.GENERIC, `Yes, or one of the other mills in Gielinor. They all work the same way. ` + + `Just take your grain to the top floor of the mill (up two ladders, there are three floors ` + + `including this one) and then place some` ], + millie => [ Emote.GENERIC, `grain into the hopper. Then you need to start the grinding process by ` + + `pulling the hopper lever. You can add more grain, but each time you add grain you have to ` + + `pull the hopper lever again.` ], + player => [ Emote.WONDERING, `So where does the flour go then?` ], + millie => [ Emote.GENERIC, `The flour appears in this room here, you'll need a pot to put the flour ` + + `into. One pot will hold the flour made by one load of grain` ], + millie => [ Emote.GENERIC, `And that's it! You now have some pots of finely ground flour of the ` + + `highest quality. Ideal for making tasty cakes or delicous bread. I'm not a cook so you'll ` + + `have to ask a cook to find` ], + millie => [ Emote.GENERIC, `out how to bake things.` ], + player => [ Emote.HAPPY, `Great! Thanks for your help.` ] + ], + `I'm fine, thanks.`, [ + player => [ Emote.GENERIC, `I'm fine, thanks.` ] + ] + ] + ]); +}; + +const gillieDialogue: npcAction = (details) => { + const { player, npc } = details; + + dialogue([ player, { npc, key: 'gillie' }], [ + gillie => [ Emote.HAPPY, `Hello, I'm Gillie the Milkmaid. What can I do for you?` ], + options => [ + `Who are you?`, [ + player => [ Emote.WONDERING, `Who are you?` ], + gillie => [ Emote.GENERIC, `My name is Gillie Groats. My father is a farmer and I milk the cows for him.` ], + player => [ Emote.WONDERING, `Do you have nay buckets of milk spare?` ], + gillie => [ Emote.GENERIC, `I'm afraid not. We need all of our milk to sell to market, ` + + `but you can milk the cow yourself if you need the milk.` ], + player => [ Emote.GENERIC, `Thanks.` ] + ], + `So how do you milk a cow then?`, [ + player => [ Emote.WONDERING, `So how do you milk a cow then?` ], + gillie => [ Emote.HAPPY, `It's very easy. First you need an empty bucket to hold the milk.` ], + gillie => [ Emote.HAPPY, `Then find a dairy cow to milk - you can't milk just any cow.` ], + player => [ Emote.SKEPTICAL, `How do I find a dairy cow?` ], + gillie => [ Emote.GENERIC, `They are easy to spot - they are dark brown and white, unlike ` + + `beef cows, which are light brown and white. We also tether them to a post to stop them ` + + `wandering around all over the place.` ], + gillie => [ Emote.GENERIC, `There are a couple very near, in this field.` ], + gillie => [ Emote.GENERIC, `Then just milk the cow and your bucket will fill with tasty, untritious milk.` ], + ], + `I'm fine, thanks.`, [ + player => [ Emote.GENERIC, `I'm fine, thanks.` ], + ] + ] + ]); +}; + +export default new RunePlugin([{ + type: ActionType.NPC_ACTION, + npcIds: npcIds.gillieGroats, + options: 'talk-to', + walkTo: true, + action: gillieDialogue +}, { + type: ActionType.NPC_ACTION, + npcIds: npcIds.millieMiller, + options: 'talk-to', + walkTo: true, + action: millieDialogue +}]); diff --git a/src/plugins/npcs/lumbridge/shopkeeper-plugin.ts b/src/plugins/npcs/lumbridge/shopkeeper-plugin.ts new file mode 100644 index 000000000..6631b0db7 --- /dev/null +++ b/src/plugins/npcs/lumbridge/shopkeeper-plugin.ts @@ -0,0 +1,10 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { openShop } from '@server/world/actor/player/action/shop-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; + +const action: npcAction = (details) => { + openShop(details.player, 'LUMBRIDGE_GENERAL_STORE'); +}; + +export default new RunePlugin({ type: ActionType.NPC_ACTION, npcIds: npcIds.shopKeeper, options: 'trade', walkTo: true, action }); diff --git a/src/plugins/npcs/lumbridge/tramp-plugin.ts b/src/plugins/npcs/lumbridge/tramp-plugin.ts new file mode 100644 index 000000000..8234451b9 --- /dev/null +++ b/src/plugins/npcs/lumbridge/tramp-plugin.ts @@ -0,0 +1,13 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; +import { npcInitAction } from '@server/world/actor/npc/npc'; +import { loopingAction } from '@server/world/actor/player/action/action'; + +const action: npcInitAction = (details) => { + const { npc } = details; + + loopingAction({ ticks: 16, npc }).event + .subscribe(() => npc.say('Welcome to RuneJS!')); +}; + +export default new RunePlugin({ type: ActionType.NPC_INIT, npcIds: npcIds.tramp, action }); diff --git a/src/plugins/npcs/shopkeeper-plugin.ts b/src/plugins/npcs/shopkeeper-plugin.ts deleted file mode 100644 index 0f22845c2..000000000 --- a/src/plugins/npcs/shopkeeper-plugin.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { npcAction } from '@server/world/mob/player/action/npc-action'; -import { openShop } from '@server/world/mob/player/action/shop-action'; -import { ActionType, RunePlugin } from '@server/plugins/plugin'; - - -const action: npcAction = (details) => { - const { player, npc } = details; - openShop(details.player, 'LUMBRIDGE_GENERAL_STORE'); -}; - -export default new RunePlugin({ type: ActionType.NPC_ACTION, npcIds: 520, options: 'trade', walkTo: true, action }); diff --git a/src/plugins/objects/bank/bank-booth-plugin.ts b/src/plugins/objects/bank/bank-booth-plugin.ts new file mode 100644 index 000000000..922410d38 --- /dev/null +++ b/src/plugins/objects/bank/bank-booth-plugin.ts @@ -0,0 +1,39 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { objectIds } from '@server/world/config/object-ids'; +import { widgets } from '@server/world/config/widget'; +import { objectAction } from '@server/world/actor/player/action/object-action'; + + +export const openBankInterface: objectAction = (details) => { + details.player.activeWidget = { + widgetId: widgets.bank.screenWidget, + secondaryWidgetId: widgets.bank.tabWidget.widgetId, + type: 'SCREEN_AND_TAB', + closeOnWalk: true + }; + details.player.outgoingPackets.sendUpdateAllWidgetItems(widgets.bank.tabWidget, details.player.inventory); + +}; + +export const depositItem: objectAction = (details) => { + // Check if player might be spawning widget clientside + if (!details.player.activeWidget || + !(details.player.activeWidget.widgetId === widgets.bank.screenWidget) || + !(details.player.activeWidget.secondaryWidgetId === widgets.bank.tabWidget.widgetId)) { + return; + } + +}; + +export default new RunePlugin([{ + type: ActionType.OBJECT_ACTION, + objectIds: objectIds.bankBooth, + options: ['use-quickly'], + walkTo: true, + action: openBankInterface +}, { + type: ActionType.ITEM_ACTION, + widgets: widgets.bank.tabWidget, + options: ['deposit-1', 'deposit-5', 'deposit-10'], + action: depositItem, +}]); diff --git a/src/plugins/objects/cows/cow-plugin.ts b/src/plugins/objects/cows/cow-plugin.ts index 5156bab03..cfe60fa6a 100644 --- a/src/plugins/objects/cows/cow-plugin.ts +++ b/src/plugins/objects/cows/cow-plugin.ts @@ -1,23 +1,58 @@ -import { objectAction } from '@server/world/mob/player/action/object-action'; -import { gameCache } from "@server/game-server"; -import { Position } from '@server/world/position'; +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { cache } from '@server/game-server'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { dialogueAction, DialogueEmote } from '@server/world/actor/player/action/dialogue-action'; +import { npcIds } from '@server/world/config/npc-ids'; +import { animationIds } from '@server/world/config/animation-ids'; +import { soundIds } from '@server/world/config/sound-ids'; +import { itemIds } from '@server/world/config/item-ids'; +import { objectIds } from '@server/world/config/object-ids'; +import { itemOnObjectAction } from '@server/world/actor/player/action/item-on-object-action'; +import { LocationObjectDefinition } from '@runejs/cache-parser'; +import { Player } from '@server/world/actor/player/player'; +function milkCow(details: { objectDefinition: LocationObjectDefinition, player: Player }): void { + const { player, objectDefinition } = details; + const emptyBucketItem = cache.itemDefinitions.get(itemIds.bucket); -export const action: objectAction = (details) => { - const { player, option, objectDefinition, object } = details; - const emptyBucketItem = gameCache.itemDefinitions.get(1925); - const milkBucketItem = gameCache.itemDefinitions.get(1927); - - if (player.hasItemInInventory(emptyBucketItem.id)) { - player.face(new Position(object.x, object.y, player.position.level)); - player.playAnimation(2305); - player.removeFirstItem(emptyBucketItem.id); - player.giveItem(milkBucketItem.id); - player.packetSender.chatboxMessage(`You ${ option } the ${ objectDefinition.name } and receive some milk.`); + if (player.hasItemInInventory(itemIds.bucket)) { + player.playAnimation(animationIds.milkCow); + player.playSound(soundIds.milkCow, 7); + player.removeFirstItem(itemIds.bucket); + player.giveItem(itemIds.bucketOfMilk); + player.sendMessage(`You milk the ${objectDefinition.name} and receive some milk.`); } else { - player.packetSender.chatboxMessage(`You need a ${ emptyBucketItem.name } to ${ option } this ${ objectDefinition.name }!`); + dialogueAction(player) + .then(d => d.npc(npcIds.gillieGroats, DialogueEmote.LAUGH_1, [`Tee hee! You've never milked a cow before, have you?`])) + .then(d => d.player(DialogueEmote.CALM_TALK_1, ['Erm... No. How could you tell?'])) + .then(d => d.npc(npcIds.gillieGroats, DialogueEmote.LAUGH_2, [`Because you're spilling milk all over the floor. What a`, 'waste! You need something to hold the milk.'])) + .then(d => d.player(DialogueEmote.CONSIDERING, [`Ah yes, I really should have guessed that one, shouldn't`, 'I?'])) + .then(d => d.npc(npcIds.gillieGroats, DialogueEmote.LAUGH_2, [`You're from the city aren't you... Try it again with a`, `${emptyBucketItem.name.toLowerCase()}.`])) + .then(d => d.player(DialogueEmote.CALM_TALK_2, [`Right, I'll do that.`])) + .then(d => { + d.close(); + }); } -}; +} + +export const actionItem: itemOnObjectAction = (details) => milkCow(details); + +export const actionInteract: objectAction = (details) => milkCow(details); -export default new RunePlugin({ type: ActionType.OBJECT_ACTION, objectIds: [8689], options: ['milk'], walkTo: true, action }); +export default new RunePlugin( + [ + { + type: ActionType.OBJECT_ACTION, + objectIds: objectIds.milkableCow, + options: 'milk', + walkTo: true, + action: actionInteract + }, + { + type: ActionType.ITEM_ON_OBJECT_ACTION, + objectIds: objectIds.milkableCow, + itemIds: itemIds.bucket, + walkTo: true, + action: actionItem + } + ]); diff --git a/src/plugins/objects/doors/door-plugin.ts b/src/plugins/objects/doors/door-plugin.ts index aa0cec3e3..4eb841f76 100644 --- a/src/plugins/objects/doors/door-plugin.ts +++ b/src/plugins/objects/doors/door-plugin.ts @@ -1,8 +1,10 @@ import { directionData, WNES } from '@server/world/direction'; import { world } from '@server/game-server'; import { Chunk } from '@server/world/map/chunk'; -import { objectAction } from '@server/world/mob/player/action/object-action'; +import { objectAction } from '@server/world/actor/player/action/object-action'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { soundIds } from '@server/world/config/sound-ids'; +import { LocationObject } from '@runejs/cache-parser'; // @TODO move to yaml config const doors = [ @@ -30,6 +32,11 @@ const doors = [ closed: 1536, open: 1537, hinge: 'LEFT' + }, + { + closed: 11993, + open: 11994, + hinge: 'RIGHT' } ]; @@ -47,7 +54,7 @@ const rightHingeDir: { [key: string]: string } = { }; export const action: objectAction = (details): void => { - let { player, object: door, position, cacheOriginal } = details; + const { player, object: door, position, cacheOriginal } = details; let opening = true; let doorConfig = doors.find(d => d.closed === door.objectId); let hingeConfig; @@ -67,24 +74,25 @@ export const action: objectAction = (details): void => { } const startDoorChunk: Chunk = world.chunkManager.getChunkForWorldPosition(position); - const startDir = WNES[door.rotation]; + const startDir = WNES[door.orientation]; const endDir = hingeConfig[startDir]; - const endPosition = position.step(opening ? 1 : -1, opening? startDir : endDir); + const endPosition = position.step(opening ? 1 : -1, opening ? startDir : endDir); - const replacementDoor = { + const replacementDoor: LocationObject = { objectId: replacementDoorId, x: endPosition.x, y: endPosition.y, level: position.level, type: door.type, - rotation: directionData[endDir].rotation + orientation: directionData[endDir].rotation }; const replacementDoorChunk = world.chunkManager.getChunkForWorldPosition(endPosition); - world.chunkManager.toggleObjects(replacementDoor, door, endPosition, position, replacementDoorChunk, startDoorChunk, !cacheOriginal); - player.packetSender.playSound(opening ? 318 : 326, 7); + world.toggleLocationObjects(replacementDoor, door, endPosition, position, replacementDoorChunk, startDoorChunk, !cacheOriginal); + // 70 = close gate, 71 = open gate, 62 = open door, 60 = close door + player.playSound(opening ? soundIds.openDoor : soundIds.closeDoor, 7); }; export default new RunePlugin({ type: ActionType.OBJECT_ACTION, objectIds: [1530, 4465, 4467, 3014, 3017, 3018, - 3019, 1536, 1537, 1533, 1531, 1534, 12348], options: [ 'open', 'close' ], walkTo: true, action }); + 3019, 1536, 1537, 1533, 1531, 1534, 12348, 11993, 11994], options: [ 'open', 'close' ], walkTo: true, action }); diff --git a/src/plugins/objects/doors/double-door-plugin.ts b/src/plugins/objects/doors/double-door-plugin.ts index 9b12a58af..39b42c10b 100644 --- a/src/plugins/objects/doors/double-door-plugin.ts +++ b/src/plugins/objects/doors/double-door-plugin.ts @@ -3,7 +3,7 @@ import { WNES } from '@server/world/direction'; import { logger } from '@runejs/logger/dist/logger'; import { world } from '@server/game-server'; import { action as doorAction } from '@server/plugins/objects/doors/door-plugin'; -import { objectAction } from '@server/world/mob/player/action/object-action'; +import { objectAction } from '@server/world/actor/player/action/object-action'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; const doubleDoors = [ @@ -36,7 +36,7 @@ const openingDelta = { }; const action: objectAction = (details) => { - let { player, object: door, position, cacheOriginal } = details; + const { player, object: door, position, cacheOriginal } = details; let doorConfig = doubleDoors.find(d => d.closed.indexOf(door.objectId) !== -1); let doorIds: number[]; let opening = true; @@ -55,7 +55,7 @@ const action: objectAction = (details) => { const leftDoorId = doorIds[0]; const rightDoorId = doorIds[1]; const hinge = leftDoorId === door.objectId ? 'LEFT' : 'RIGHT'; - const direction = WNES[door.rotation]; + const direction = WNES[door.orientation]; let deltaX = 0; let deltaY = 0; const otherDoorId = hinge === 'LEFT' ? rightDoorId : leftDoorId; diff --git a/src/plugins/objects/doors/gate-plugin.ts b/src/plugins/objects/doors/gate-plugin.ts index 109f3fc4d..8a308a726 100644 --- a/src/plugins/objects/doors/gate-plugin.ts +++ b/src/plugins/objects/doors/gate-plugin.ts @@ -2,9 +2,10 @@ import { Position } from '@server/world/position'; import { directionData, WNES } from '@server/world/direction'; import { logger } from '@runejs/logger/dist/logger'; import { world } from '@server/game-server'; -import { ModifiedLandscapeObject } from '@server/world/map/landscape-object'; -import { objectAction } from '@server/world/mob/player/action/object-action'; +import { ModifiedLocationObject } from '@server/world/map/location-object'; +import { objectAction } from '@server/world/actor/player/action/object-action'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { soundIds } from '@server/world/config/sound-ids'; const gates = [ { @@ -13,25 +14,33 @@ const gates = [ hinge: 'LEFT', secondary: 1553, secondaryOpen: 1556 + }, + { + main: 12986, + mainOpen: 12988, + hinge: 'LEFT', + secondary: 12987, + secondaryOpen: 12989 } ]; // @TODO clean up this disgusting code const action: objectAction = (details) => { - let { object: gate, position, player, cacheOriginal } = details; + const { player, cacheOriginal } = details; + let { object: gate, position } = details; - if((gate as ModifiedLandscapeObject).metadata) { - const metadata = (gate as ModifiedLandscapeObject).metadata; + if((gate as ModifiedLocationObject).metadata) { + const metadata = (gate as ModifiedLocationObject).metadata; - world.chunkManager.toggleObjects(metadata.originalMain, metadata.main, metadata.originalMainPosition, metadata.mainPosition, metadata.originalMainChunk, metadata.mainChunk, true); - world.chunkManager.toggleObjects(metadata.originalSecond, metadata.second, metadata.originalSecondPosition, metadata.secondPosition, metadata.originalSecondChunk, metadata.secondChunk, true); - player.packetSender.playSound(327, 7); // @TODO find correct gate closing sound + world.toggleLocationObjects(metadata.originalMain, metadata.main, metadata.originalMainPosition, metadata.mainPosition, metadata.originalMainChunk, metadata.mainChunk, true); + world.toggleLocationObjects(metadata.originalSecond, metadata.second, metadata.originalSecondPosition, metadata.secondPosition, metadata.originalSecondChunk, metadata.secondChunk, true); + player.playSound(soundIds.closeGate, 7); } else { let details = gates.find(g => g.main === gate.objectId); let clickedSecondary = false; let secondGate; let hinge; - let direction = WNES[gate.rotation]; + let direction = WNES[gate.orientation]; let hingeChunk, gateSecondPosition; if(!details) { @@ -79,7 +88,7 @@ const action: objectAction = (details) => { const pos = new Position(gate.x + deltaX, gate.y + deltaY, gate.level); hingeChunk = world.chunkManager.getChunkForWorldPosition(pos); gate = hingeChunk.getCacheObject(details.main, pos); - direction = WNES[gate.rotation]; + direction = WNES[gate.orientation]; position = pos; } else { hinge = details.hinge; @@ -130,15 +139,13 @@ const action: objectAction = (details) => { } } - player.packetSender.chatboxMessage(hinge + ' ' + direction); - - let leftHingeDirections: { [key: string]: string } = { + const leftHingeDirections: { [key: string]: string } = { 'NORTH': 'WEST', 'SOUTH': 'EAST', 'WEST': 'SOUTH', 'EAST': 'NORTH' }; - let rightHingeDirections: { [key: string]: string } = { + const rightHingeDirections: { [key: string]: string } = { 'NORTH': 'EAST', 'SOUTH': 'WEST', 'WEST': 'NORTH', @@ -174,16 +181,16 @@ const action: objectAction = (details) => { y: newPosition.y, level: newPosition.level, type: gate.type, - rotation: directionData[newDirection].rotation - } as ModifiedLandscapeObject; + orientation: directionData[newDirection].rotation + } as ModifiedLocationObject; const newSecond = { objectId: details.secondaryOpen, x: newSecondPosition.x, y: newSecondPosition.y, level: newSecondPosition.level, type: gate.type, - rotation: directionData[newDirection].rotation - } as ModifiedLandscapeObject; + orientation: directionData[newDirection].rotation + } as ModifiedLocationObject; const metadata = { second: JSON.parse(JSON.stringify(newSecond)), @@ -203,11 +210,13 @@ const action: objectAction = (details) => { newHinge.metadata = metadata; newSecond.metadata = metadata; - world.chunkManager.toggleObjects(newHinge, gate, newPosition, position, newHingeChunk, hingeChunk, !cacheOriginal); - world.chunkManager.toggleObjects(newSecond, secondGate, newSecondPosition, gateSecondPosition, newSecondChunk, gateSecondChunk, !cacheOriginal); - player.packetSender.playSound(328, 7); // @TODO find correct gate opening sound + world.toggleLocationObjects(newHinge, gate, newPosition, position, newHingeChunk, hingeChunk, !cacheOriginal); + world.toggleLocationObjects(newSecond, secondGate, newSecondPosition, gateSecondPosition, newSecondChunk, gateSecondChunk, !cacheOriginal); + player.playSound(soundIds.openGate, 7); } }; -export default new RunePlugin({ type: ActionType.OBJECT_ACTION, objectIds: [1551, 1552, 1553, 1556], - options: [ 'open', 'close' ], walkTo: true, action }); +export default new RunePlugin({ + type: ActionType.OBJECT_ACTION, objectIds: [1551, 1552, 1553, 1556, 12986, 12987, 12988, 12989], + options: ['open', 'close'], walkTo: true, action +}); diff --git a/src/plugins/objects/dungeon-entrances/taverly-dungeon-ladder-plugin.ts b/src/plugins/objects/dungeon-entrances/taverly-dungeon-ladder-plugin.ts new file mode 100644 index 000000000..30ba40cac --- /dev/null +++ b/src/plugins/objects/dungeon-entrances/taverly-dungeon-ladder-plugin.ts @@ -0,0 +1,42 @@ +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { objectIds } from '@server/world/config/object-ids'; +import { World } from '@server/world/world'; +import { animationIds } from '@server/world/config/animation-ids'; + +export const enterDungeon: objectAction = (details) => { + const loc = details.player.position.clone(); + loc.y += 6400; + details.player.playAnimation(animationIds.climbLadder); + setTimeout(() => { + details.player.teleport(loc); + }, World.TICK_LENGTH); +}; + + +export const exitDungeon: objectAction = (details) => { + const loc = details.player.position.clone(); + loc.y -= 6400; + details.player.playAnimation(animationIds.climbLadder); + setTimeout(() => { + details.player.teleport(loc); + }, World.TICK_LENGTH); +}; + + +export default new RunePlugin([ + { + type: ActionType.OBJECT_ACTION, + objectIds: objectIds.ladders.taverlyDungeonOverworld, + options: ['climb-down'], + walkTo: true, + action: enterDungeon + }, + { + type: ActionType.OBJECT_ACTION, + objectIds: objectIds.ladders.taverlyDungeonUnderground, + options: ['climb-up'], + walkTo: true, + action: exitDungeon + } +]); diff --git a/src/plugins/objects/ladders/ladder-plugin.ts b/src/plugins/objects/ladders/ladder-plugin.ts index e239acd06..c19e42d6f 100644 --- a/src/plugins/objects/ladders/ladder-plugin.ts +++ b/src/plugins/objects/ladders/ladder-plugin.ts @@ -1,5 +1,5 @@ -import { objectAction } from '@server/world/mob/player/action/object-action'; -import { dialogueAction } from '@server/world/mob/player/action/dialogue-action'; +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { dialogueAction } from '@server/world/actor/player/action/dialogue-action'; import { World } from '@server/world/world'; import { Position } from '@server/world/position'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; @@ -29,7 +29,7 @@ export const action: objectAction = (details) => { action({...details, option: `climb-${direction}`}); return; } - }).catch(error => console.error(error)); + }); return; } @@ -41,7 +41,7 @@ export const action: objectAction = (details) => { if (!details.objectDefinition.name.startsWith('Stair')) { player.playAnimation(up ? 828 : 827); } - player.packetSender.chatboxMessage(`You climb ${option.slice(6)} the ${details.objectDefinition.name.toLowerCase()}.`); + player.sendMessage(`You climb ${option.slice(6)} the ${details.objectDefinition.name.toLowerCase()}.`); setTimeout(() => { details.player.teleport(new Position(position.x, position.y, level)); }, World.TICK_LENGTH); diff --git a/src/plugins/objects/mill/flour-bin-plugin.ts b/src/plugins/objects/mill/flour-bin-plugin.ts new file mode 100644 index 000000000..19af4838f --- /dev/null +++ b/src/plugins/objects/mill/flour-bin-plugin.ts @@ -0,0 +1,51 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { itemIds } from '@server/world/config/item-ids'; +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { soundIds } from '@server/world/config/sound-ids'; +import { itemOnObjectAction } from '@server/world/actor/player/action/item-on-object-action'; +import { LocationObjectDefinition } from '@runejs/cache-parser'; +import { Player } from '@server/world/actor/player/player'; + + +function flourBin(details: { objectDefinition: LocationObjectDefinition, player: Player }): void { + const { player, objectDefinition } = details; + + if (!details.player.metadata['flour']) { + player.sendMessage(`The ${objectDefinition.name.toLowerCase()} is already empty. You need to place wheat in the hopper upstairs `); + player.sendMessage(`first.`); + return; + } + if (player.hasItemInInventory(itemIds.pot)) { + player.playSound(soundIds.potContentModified, 7); + player.removeFirstItem(itemIds.pot); + player.giveItem(itemIds.potOfFlour); + details.player.metadata['flour'] -= 1; + } else { + player.sendMessage(`You need a pot to hold the flour in.`); + } +} + +const actionInteract: objectAction = (details) => { + flourBin(details); +}; + +const actionItem: itemOnObjectAction = (details) => { + flourBin(details); +}; + +export default new RunePlugin([ + { + type: ActionType.ITEM_ON_OBJECT_ACTION, + objectIds: [1782], + itemIds: [itemIds.pot], + walkTo: true, + action: actionItem + }, + { + type: ActionType.OBJECT_ACTION, + objectIds: [1782], + options: ['empty'], + walkTo: true, + action: actionInteract + } +]); diff --git a/src/plugins/objects/mill/hopper-controls-plugin.ts b/src/plugins/objects/mill/hopper-controls-plugin.ts index aa67e6547..c8318cfc9 100644 --- a/src/plugins/objects/mill/hopper-controls-plugin.ts +++ b/src/plugins/objects/mill/hopper-controls-plugin.ts @@ -1,42 +1,49 @@ -import { objectAction } from '@server/world/mob/player/action/object-action'; +import { objectAction } from '@server/world/actor/player/action/object-action'; import { World } from '@server/world/world'; import { world } from '@server/game-server'; import { Position } from '@server/world/position'; -import { LandscapeObject } from '@runejs/cache-parser'; +import { LocationObject } from '@runejs/cache-parser'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; export const action: objectAction = (details) => { - if (details.player.metadata['busy']) { - return; - } + details.player.busy = true; + details.player.playAnimation(3571); + details.player.playSound(2400, 5); + const newHopper: LocationObject = { + objectId: 2722, + x: details.object.x, + y: details.object.y, + level: details.object.level, + type: details.object.type, + orientation: details.object.orientation + }; - if ((details.player.metadata['flour'] && details.player.metadata['flour'] === 30) || - (details.player.metadata['flour'] && details.player.metadata['grain'] && - details.player.metadata['flour'] + details.player.metadata['grain'] >= 30)) { - details.player.packetSender.chatboxMessage(`There is currently too much flour in the flour bin.`); - return; - } - details.player.metadata['busy'] = true; - details.player.playAnimation(832); + world.replaceLocationObject(2722, details.object, 1); setTimeout(() => { if (details.player.metadata['grain'] && details.player.metadata['grain'] >= 1) { - details.player.packetSender.chatboxMessage(`You operate the hopper. The grain slide down the chute.`); + details.player.sendMessage(`You operate the hopper. The grain slide down the chute.`); if (!details.player.metadata['flour']) { details.player.metadata['flour'] = 0; } details.player.metadata['flour'] += details.player.metadata['grain']; details.player.metadata['grain'] = 0; const flourBinPos = new Position(3166, 3306); - const fullFlourBin: LandscapeObject = {objectId: 1782, x: 3166, y:3306, rotation: 0, level: 0, type: 10}; - world.chunkManager.addLandscapeObject(fullFlourBin, flourBinPos); + const fullFlourBin: LocationObject = {objectId: 1782, x: 3166, y: 3306, orientation: 0, level: 0, type: 10}; + world.addLocationObject(fullFlourBin, flourBinPos); } else { - details.player.packetSender.chatboxMessage(`You operate the hopper. Nothing interesting happens.`); + details.player.sendMessage(`You operate the hopper. Nothing interesting happens.`); } - details.player.metadata['busy'] = false; + details.player.busy = false; }, World.TICK_LENGTH); }; -export default new RunePlugin({ type: ActionType.OBJECT_ACTION, objectIds: [2718], options: ['operate'], walkTo: true, action }); +export default new RunePlugin({ + type: ActionType.OBJECT_ACTION, + objectIds: [2718], + options: ['operate'], + walkTo: true, + action +}); diff --git a/src/plugins/objects/mill/hopper-plugin.ts b/src/plugins/objects/mill/hopper-plugin.ts new file mode 100644 index 000000000..d9f675a26 --- /dev/null +++ b/src/plugins/objects/mill/hopper-plugin.ts @@ -0,0 +1,32 @@ +import { World } from '@server/world/world'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { itemIds } from '@server/world/config/item-ids'; +import { itemOnObjectAction } from '@server/world/actor/player/action/item-on-object-action'; + + +export const action: itemOnObjectAction = (details) => { + if ((details.player.metadata['grain'] && details.player.metadata['grain'] === 1)) { + details.player.sendMessage(`There is already grain in the hopper.`); + return; + } + details.player.busy = true; + details.player.playAnimation(3572); + details.player.playSound(2576, 5); + + setTimeout(() => { + details.player.removeFirstItem(itemIds.grain); + details.player.sendMessage(`You put the grain in the hopper. You should now pull the lever nearby to operate`); + details.player.sendMessage(`the hopper.`); + details.player.metadata['grain'] = 1; + details.player.busy = false; + }, World.TICK_LENGTH); + +}; + +export default new RunePlugin({ + type: ActionType.ITEM_ON_OBJECT_ACTION, + objectIds: [2714], + itemIds: [itemIds.grain], + walkTo: true, + action +}); diff --git a/src/plugins/objects/pickables/pickables-plugin.ts b/src/plugins/objects/pickables/pickables-plugin.ts index e63f29f63..8c44364e0 100644 --- a/src/plugins/objects/pickables/pickables-plugin.ts +++ b/src/plugins/objects/pickables/pickables-plugin.ts @@ -1,45 +1,44 @@ -import { objectAction } from '@server/world/mob/player/action/object-action'; -import { gameCache, world } from '@server/game-server'; +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { cache, world } from '@server/game-server'; import { World } from '@server/world/world'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { itemIds } from '@server/world/config/item-ids'; export const action: objectAction = (details) => { - if(details.player.metadata['busy']) { - return; - } - details.player.metadata['busy'] = true; + details.player.busy = true; details.player.playAnimation(827); - let itemId: number = 1965; + let itemId: number = itemIds.cabbage; let prefix = 'some'; switch (details.objectDefinition.name) { case 'Wheat': - itemId = 1947; + itemId = itemIds.grain; break; case 'Onion': - itemId = 1957; + itemId = itemIds.onion; prefix = 'an'; break; case 'Potato': prefix = 'a'; - itemId = 1942; + itemId = itemIds.potato; break; case 'Flax': - itemId = 1779; + itemId = itemIds.flax; break; case 'Cabbage': default: - itemId = 1965; + itemId = itemIds.cabbage; break; } - const pickedItem = gameCache.itemDefinitions.get(itemId); + const pickedItem = cache.itemDefinitions.get(itemId); setTimeout(() => { - details.player.packetSender.chatboxMessage(`You ${details.option} the ${details.objectDefinition.name.toLowerCase()} and receive ${prefix} ${pickedItem.name.toLowerCase()}.`); + details.player.sendMessage(`You ${details.option} the ${details.objectDefinition.name.toLowerCase()} and receive ${prefix} ${pickedItem.name.toLowerCase()}.`); + details.player.playSound(2581, 7); if (details.objectDefinition.name !== 'Flax' || Math.floor(Math.random() * 10) === 1) { - world.chunkManager.removeLandscapeObjectTemporarily(details.object, details.position, 30); + world.removeLocationObjectTemporarily(details.object, details.position, 30); } details.player.giveItem(pickedItem.id); - details.player.metadata['busy'] = false; + details.player.busy = false; }, World.TICK_LENGTH); }; diff --git a/src/plugins/player/login-unlock-emotes-plugin.ts b/src/plugins/player/login-unlock-emotes-plugin.ts new file mode 100644 index 000000000..9212638f5 --- /dev/null +++ b/src/plugins/player/login-unlock-emotes-plugin.ts @@ -0,0 +1,10 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { playerInitAction } from '@server/world/actor/player/player'; +import { unlockEmotes } from '@server/plugins/buttons/player-emotes-plugin'; + +export const action: playerInitAction = (details) => { + const { player } = details; + unlockEmotes(player); +}; + +export default new RunePlugin({ type: ActionType.PLAYER_INIT, action }); diff --git a/src/plugins/player/login-update-settings-plugin.ts b/src/plugins/player/login-update-settings-plugin.ts new file mode 100644 index 000000000..37b7f95dc --- /dev/null +++ b/src/plugins/player/login-update-settings-plugin.ts @@ -0,0 +1,26 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { playerInitAction } from '@server/world/actor/player/player'; +import { validateSettings } from '@server/world/actor/player/player-data'; +import { widgetScripts } from '@server/world/config/widget'; + +export const action: playerInitAction = (details) => { + const { player } = details; + + validateSettings(player); + + const settings = player.settings; + player.outgoingPackets.updateClientConfig(widgetScripts.brightness, settings.screenBrightness); + player.outgoingPackets.updateClientConfig(widgetScripts.mouseButtons, settings.twoMouseButtonsEnabled ? 0 : 1); + player.outgoingPackets.updateClientConfig(widgetScripts.splitPrivateChat, settings.splitPrivateChatEnabled ? 1 : 0); + player.outgoingPackets.updateClientConfig(widgetScripts.chatEffects, settings.chatEffectsEnabled ? 0 : 1); + player.outgoingPackets.updateClientConfig(widgetScripts.acceptAid, settings.acceptAidEnabled ? 1 : 0); + player.outgoingPackets.updateClientConfig(widgetScripts.musicVolume, settings.musicVolume); + player.outgoingPackets.updateClientConfig(widgetScripts.soundEffectVolume, settings.soundEffectVolume); + player.outgoingPackets.updateClientConfig(widgetScripts.areaEffectVolume, settings.areaEffectVolume); + player.outgoingPackets.updateClientConfig(widgetScripts.runMode, settings.runEnabled ? 1 : 0); + player.outgoingPackets.updateClientConfig(widgetScripts.autoRetaliate, settings.autoRetaliateEnabled ? 0 : 1); + player.outgoingPackets.updateClientConfig(widgetScripts.attackStyle, settings.attackStyle); + player.outgoingPackets.updateClientConfig(widgetScripts.bankInsertMode, settings.bankInsertMode); +}; + +export default new RunePlugin({ type: ActionType.PLAYER_INIT, action }); diff --git a/src/plugins/plugin-loader.ts b/src/plugins/plugin-loader.ts index f5f9e360a..6fd8ca4b2 100644 --- a/src/plugins/plugin-loader.ts +++ b/src/plugins/plugin-loader.ts @@ -2,6 +2,34 @@ import * as fs from 'fs'; import * as util from 'util'; import { RunePlugin } from '@server/plugins/plugin'; +export const basicStringFilter = (pluginValues: string | string[], searchValue: string): boolean => { + if(Array.isArray(pluginValues)) { + if(pluginValues.indexOf(searchValue) === -1) { + return false; + } + } else { + if(pluginValues !== searchValue) { + return false; + } + } + + return true; +}; + +export const basicNumberFilter = (pluginValues: number | number[], searchValue: number): boolean => { + if(Array.isArray(pluginValues)) { + if(pluginValues.indexOf(searchValue) === -1) { + return false; + } + } else { + if(pluginValues !== searchValue) { + return false; + } + } + + return true; +}; + export const pluginFilter = (pluginIds: number | number[], searchId: number, pluginOptions?: string | string[], searchOption?: string): boolean => { if(Array.isArray(pluginIds)) { if(pluginIds.indexOf(searchId) === -1) { diff --git a/src/plugins/plugin.ts b/src/plugins/plugin.ts index bb191239d..b3f681d26 100644 --- a/src/plugins/plugin.ts +++ b/src/plugins/plugin.ts @@ -1,29 +1,86 @@ -import { NpcActionPlugin } from '@server/world/mob/player/action/npc-action'; -import { ObjectActionPlugin } from '@server/world/mob/player/action/object-action'; -import { ButtonActionPlugin } from '@server/world/mob/player/action/button-action'; -import { ItemOnItemActionPlugin } from '@server/world/mob/player/action/item-on-item-action'; +import { ItemOnObjectActionPlugin } from '@server/world/actor/player/action/item-on-object-action'; +import { ItemOnNpcActionPlugin } from '@server/world/actor/player/action/item-on-npc-action'; +import { NpcActionPlugin } from '@server/world/actor/player/action/npc-action'; +import { CommandActionPlugin } from '@server/world/actor/player/action/input-command-action'; +import { WidgetActionPlugin } from '@server/world/actor/player/action/widget-action'; +import { ObjectActionPlugin } from '@server/world/actor/player/action/object-action'; +import { NpcInitPlugin } from '@server/world/actor/npc/npc'; +import { Player, PlayerInitPlugin } from '@server/world/actor/player/player'; +import { ItemOnItemActionPlugin } from '@server/world/actor/player/action/item-on-item-action'; +import { ButtonActionPlugin } from '@server/world/actor/player/action/button-action'; +import { WorldItemActionPlugin } from '@server/world/actor/player/action/world-item-action'; +import { ItemActionPlugin } from '@server/world/actor/player/action/item-action'; +import { QuestPlugin } from '@server/world/config/quests'; export enum ActionType { BUTTON = 'button', - ITEM_ON_ITEM = 'item_on_item', + WIDGET_ACTION = 'widget_action', + ITEM_ON_ITEM_ACTION = 'item_on_item_action', + ITEM_ACTION = 'item_action', + WORLD_ITEM_ACTION = 'world_item_action', NPC_ACTION = 'npc_action', OBJECT_ACTION = 'object_action', - COMMAND = 'command' + ITEM_ON_OBJECT_ACTION = 'item_on_object_action', + ITEM_ON_NPC_ACTION = 'item_on_npc_action', + COMMAND = 'command', + PLAYER_INIT = 'player_init', + NPC_INIT = 'npc_init', + QUEST = 'quest' +} + +export interface QuestAction { + questId: string; + stage: string; } export interface ActionPlugin { + // The type of action to perform. type: ActionType; + // [optional] Details regarding what quest this action is for. + questAction?: QuestAction; +} + +export function sort(plugins: ActionPlugin[]): ActionPlugin[] { + return plugins.sort(plugin => plugin.questAction !== undefined ? -1 : 1); +} + +export function questFilter(player: Player, plugin: ActionPlugin): boolean { + if(!plugin.questAction) { + return true; + } + + const questId = plugin.questAction.questId; + const playerQuest = player.quests.find(quest => quest.questId === questId); + if(!playerQuest) { + // @TODO quest requirements + return plugin.questAction.stage === 'NOT_STARTED'; + } + + return playerQuest.stage === plugin.questAction.stage; } export class RunePlugin { - public actions: (NpcActionPlugin | ObjectActionPlugin | ButtonActionPlugin | ItemOnItemActionPlugin)[]; + public actions: (NpcActionPlugin | ObjectActionPlugin | ButtonActionPlugin | ItemOnItemActionPlugin | ItemOnObjectActionPlugin | ItemOnNpcActionPlugin | + CommandActionPlugin | WidgetActionPlugin | ItemActionPlugin | WorldItemActionPlugin | PlayerInitPlugin | NpcInitPlugin | QuestPlugin)[]; - public constructor(actions: NpcActionPlugin | ObjectActionPlugin | ButtonActionPlugin | ItemOnItemActionPlugin | - (NpcActionPlugin | ObjectActionPlugin | ButtonActionPlugin | ItemOnItemActionPlugin)[]) { + public constructor(actions: NpcActionPlugin | ObjectActionPlugin | ButtonActionPlugin | ItemOnItemActionPlugin | ItemOnObjectActionPlugin | + CommandActionPlugin | WidgetActionPlugin | ItemActionPlugin | WorldItemActionPlugin | PlayerInitPlugin | NpcInitPlugin | QuestPlugin | ItemOnNpcActionPlugin | + (NpcActionPlugin | ObjectActionPlugin | ButtonActionPlugin | ItemOnItemActionPlugin | ItemOnObjectActionPlugin | ItemOnNpcActionPlugin | + CommandActionPlugin | WidgetActionPlugin | ItemActionPlugin | WorldItemActionPlugin | PlayerInitPlugin | NpcInitPlugin | QuestPlugin)[], quest?: QuestAction) { if(!Array.isArray(actions)) { + if(quest !== undefined && !actions.questAction) { + actions.questAction = quest; + } this.actions = [actions]; } else { + if(quest !== undefined) { + actions.forEach(action => { + if(!action.questAction) { + action.questAction = quest; + } + }); + } this.actions = actions; } } diff --git a/src/plugins/quests/cooks-assistant-quest.ts b/src/plugins/quests/cooks-assistant-quest.ts new file mode 100644 index 000000000..e7172028d --- /dev/null +++ b/src/plugins/quests/cooks-assistant-quest.ts @@ -0,0 +1,275 @@ +import { npcAction } from '@server/world/actor/player/action/npc-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; +import { Quest } from '@server/world/config/quests'; +import { dialogue, DialogueTree, Emote, execute, goto } from '@server/world/actor/dialogue'; +import { Player } from '@server/world/actor/player/player'; +import { Skill } from '@server/world/actor/skills'; +import { itemIds } from '@server/world/config/item-ids'; +import { QuestProgress } from '@server/world/actor/player/player-data'; + +const quest: Quest = { + id: 'cooksAssistant', + questTabId: 27, + name: `Cook's Assistant`, + points: 1, + stages: { + NOT_STARTED: `I can start this quest by speaking to the Cook in the ` + + `Kitchen on the ground floor of Lumbridge Castle.`, + COLLECTING: (player: Player) => { + let questLog = `It's the Duke of Lumbridge's birthday and I have to help ` + + `his Cook make him a birthday cake. To do this I need to ` + + `bring him the following ingredients:\n`; + const quest = player.getQuest('cooksAssistant'); + + if(player.hasItemInInventory(itemIds.bucketOfMilk) || quest.attributes.givenMilk) { + questLog += `I have found a bucket of milk to give to the cook.\n`; + } else { + questLog += `I need to find a bucket of milk. There's a cattle field east ` + + `of Lumbridge, I should make sure I take an empty bucket with me.\n`; + } + + if(player.hasItemInInventory(itemIds.potOfFlour) || quest.attributes.givenFlour) { + questLog += `I have found a pot of flour to give to the cook.\n`; + } else { + questLog += `I need to find a pot of flour. There's a mill found north-` + + `west of Lumbridge, I should take an empty pot with me.\n`; + } + + if(player.hasItemInInventory(itemIds.egg) || quest.attributes.givenEgg) { + questLog += `I have found an egg to give to the cook.\n`; + } else { + questLog += `I need to find an egg. The cook normally gets his eggs from ` + + `the Groats' farm, found just to the west of the cattle field.`; + } + + return questLog; + }, + COMPLETE: { color: 0, text: `It was the Duke of Lumbridge's birthday, but his cook had ` + + `forgotten to buy the ingredients he needed to make him a ` + + `cake. I brought the cook an egg, some flour and some milk ` + + `and the cook made a delicious looking cake with them.\n\n` + + `As a reward he now lets me use his high quality range ` + + `which lets me burn things less whenever I wish to cook ` + + `there.\n\n` + + `QUEST COMPLETE!` } + }, + completion: { + rewards: [ '300 Cooking XP' ], + onComplete: (player: Player): void => { + player.skills.addExp(Skill.COOKING, 300); + }, + itemId: 1891, + modelZoom: 240, + modelRotationX: 180, + modelRotationY: 180 + } +}; + +function dialogueIngredientQuestions(): Function { + return (options, tag_INGREDIENT_QUESTIONS) => [ + `Where do I find some flour?`, [ + player => [ Emote.GENERIC, `Where do I find some flour?` ], + cook => [ Emote.GENERIC, `There is a Mill fairly close, go North and then West. Mill Lane Mill ` + + `is just off the road to Draynor. I usually get my flour from there.` ], + cook => [ Emote.HAPPY, `Talk to Millie, she'll help, she's a lovely girl and a fine Miller.` ], + goto('tag_INGREDIENT_QUESTIONS') + ], + `How about milk?`, [ + player => [ Emote.GENERIC, `How about milk?` ], + cook => [ Emote.GENERIC, `There is a cattle field on the other side of the river, just across ` + + `the road from Groats' Farm.` ], + cook => [ Emote.HAPPY, `Talk to Gillie Groats, she look after the Dairy Cows - ` + + `she'll tell you everything you need to know about milking cows!` ], + goto('tag_INGREDIENT_QUESTIONS') + ], + `And eggs? Where are they found?`, [ + player => [ Emote.GENERIC, `And eggs? Where are they found?` ], + cook => [ Emote.GENERIC, `I normally get my eggs from the Groats' farm, on the other side of ` + + `the river.` ], + cook => [ Emote.GENERIC, `But any chicken should lay eggs.` ], + goto('tag_INGREDIENT_QUESTIONS') + ], + `Actually, I know where to find this stuff.`, [ + player => [ Emote.GENERIC, `I've got all the information I need. Thanks.` ] + ] + ]; +} + +const startQuestAction: npcAction = (details) => { + const { player, npc } = details; + + dialogue([ player, { npc, key: 'cook' }], [ + cook => [ Emote.WORRIED, `What am I to do?` ], + options => [ + `What's wrong?`, [], + `Can you make me a cake?`, [ + player => [ Emote.HAPPY, `You're a cook, why don't you bake me a cake?` ], + cook => [ Emote.SAD, `*sniff* Don't talk to me about cakes...` ] + ], + `You don't look very happy.`, [ + player => [ Emote.WORRIED, `You don't look very happy.` ], + cook => [ Emote.SAD, `No, I'm not. The world is caving in around me - I am overcome by dark feelings ` + + `of impending doom.` ], + options => [ + `What's wrong?`, [], + `I'd take the rest of the day off if I were you.`, [ + player => [ Emote.GENERIC, `I'd take the rest of the day off if I were you.` ], + cook => [ Emote.WORRIED, `No, that's the worst thing I could do. I'd get in terrible trouble.` ], + player => [ Emote.SKEPTICAL, `Well maybe you need to take a holiday...` ], + cook => [ Emote.SAD, `That would be nice, but the Duke doesn't allow holidays for core staff.` ], + player => [ Emote.LAUGH, `Hmm, why not run away to the sea and start a new life as a Pirate?` ], + cook => [ Emote.SKEPTICAL, `My wife gets sea sick, and I have an irrational fear of eyepatches. ` + + `I don't see it working myself.` ], + player => [ Emote.WORRIED, `I'm afraid I've run out of ideas.` ], + cook => [ Emote.SAD, `I know I'm doomed.` ] + ] + ] + ], + `Nice hat!`, [ + player => [ Emote.HAPPY, `Nice hat!` ], + cook => [ Emote.SKEPTICAL, `Err thank you. It's a pretty ordinary cooks hat really.` ], + player => [ Emote.HAPPY, `Still, suits you. The trousers are pretty special too.` ], + cook => [ Emote.SKEPTICAL, `It's all standard cook's issue uniform...` ], + player => [ Emote.POMPOUS, `The whole hat, apron, striped trousers ensemble - it works. It makes you ` + + `look like a real cook.` ], + cook => [ Emote.ANGRY, `I am a real cook! I haven't got time to be chatting about Culinary Fashion. ` + + `I am in desperate need of help!` ] + ] + ], + player => [ Emote.HAPPY, `What's wrong?` ], + cook => [ Emote.WORRIED, `Oh dear, oh dear, oh dear, I'm in a terrible terrible ` + + ` mess! It's the Duke's birthday today, and I should be making him a lovely big birthday cake.` ], + cook => [ Emote.WORRIED, `I've forgotten to buy the ingredients. I'll never get ` + + `them in time now. He'll sack me! What will I do? I have four children and a goat to ` + + `look after. Would you help me? Please?` ], + options => [ + `I'm always happy to help a cook in distress.`, [ + execute(() => { + player.setQuestStage('cooksAssistant', 'COLLECTING'); + }), + player => [ Emote.GENERIC, `Yes, I'll help you.` ], + cook => [ Emote.HAPPY, `Oh thank you, thank you. I need milk, an egg and flour. I'd be very grateful ` + + `if you can get them for me.` ], + player => [ Emote.GENERIC, `So where do I find these ingredients then?` ], + dialogueIngredientQuestions() + ], + `I can't right now, maybe later.`, [ + player => [ Emote.GENERIC, `No, I don't feel like it. Maybe later.` ], + cook => [ Emote.ANGRY, `Fine. I always knew you Adventurer types were callous beasts. ` + + `Go on your merry way!` ] + ] + ] + ]); +}; + +function youStillNeed(quest: QuestProgress): DialogueTree { + return [ + text => `You still need to get\n` + + `${!quest.attributes.givenMilk ? `A bucket of milk. ` : ``}${!quest.attributes.givenFlour ? `A pot of flour. ` : ``}${!quest.attributes.givenEgg ? `An egg.` : ``}`, + options => [ + `I'll get right on it.`, [ + player => [Emote.GENERIC, `I'll get right on it.`] + ], + `Can you remind me how to find these things again?`, [ + player => [Emote.GENERIC, `So where do I find these ingredients then?`], + dialogueIngredientQuestions() + ] + ] + ]; +} + +const handInIngredientsAction: npcAction = (details) => { + const { player, npc } = details; + + const dialogueTree: DialogueTree = [ + cook => [Emote.GENERIC, `How are you getting on with finding the ingredients?`] + ]; + + const quest = player.quests.find(quest => quest.questId === 'cooksAssistant'); + + const ingredients = [ + { itemId: itemIds.bucketOfMilk, text: `Here's a bucket of milk.`, attr: 'givenMilk' }, + { itemId: itemIds.potOfFlour, text: `Here's a pot of flour.`, attr: 'givenFlour' }, + { itemId: itemIds.egg, text: `Here's a fresh egg.`, attr: 'givenEgg' } + ]; + + for(const ingredient of ingredients) { + if(quest.attributes[ingredient.attr]) { + quest.attributes.ingredientCount++; + continue; + } + + if(!player.hasItemInInventory(ingredient.itemId)) { + continue; + } + + dialogueTree.push( + player => [Emote.GENERIC, ingredient.text], + execute(() => { + const quest = player.quests.find(quest => quest.questId === 'cooksAssistant'); + + if(player.removeFirstItem(ingredient.itemId) !== -1) { + quest.attributes[ingredient.attr] = true; + } + }) + ); + } + + dialogueTree.push( + goto(() => { + const count = [ quest.attributes.givenMilk, quest.attributes.givenFlour, quest.attributes.givenEgg ] + .filter(value => value === true).length; + + if(count === 3) { + return 'tag_ALL_INGREDIENTS'; + } else if(count === 0) { + return 'tag_NO_INGREDIENTS'; + } else { + return 'tag_SOME_INGREDIENTS'; + } + }), + (subtree, tag_ALL_INGREDIENTS) => [ + cook => [Emote.HAPPY, `You've brought me everything I need! I am saved! Thank you!`], + player => [Emote.WONDERING, `So do I get to go to the Duke's Party?`], + cook => [Emote.SAD, `I'm afraid not, only the big cheeses get to dine with the Duke.`], + player => [Emote.GENERIC, `Well, maybe one day I'll be important enough to sit on the Duke's table.`], + cook => [Emote.SKEPTICAL, `Maybe, but I won't be holding my breath.`], + execute(() => { + player.setQuestStage('cooksAssistant', 'COMPLETE'); + }) + ], + (subtree, tag_NO_INGREDIENTS) => [ + player => [Emote.GENERIC, `I haven't got any of them yet, I'm still looking.`], + cook => [Emote.SAD, `Please get the ingredients quickly. I'm running out of time! ` + + `The Duke will throw me into the streets!`], + ...youStillNeed(quest) + ], + (subtree, tag_SOME_INGREDIENTS) => [ + cook => [Emote.SAD, `Thanks for the ingredients you have got so far, please get the rest quickly. ` + + `I'm running out of time! The Duke will throw me into the streets!`], + ...youStillNeed(quest) + ] + ); + + dialogue([ player, { npc, key: 'cook' }], dialogueTree); +}; + +export default new RunePlugin([{ + type: ActionType.QUEST, + quest +}, { + type: ActionType.NPC_ACTION, + questAction: { questId: 'cooksAssistant', stage: 'NOT_STARTED' }, + npcIds: npcIds.lumbridgeCook, + options: 'talk-to', + walkTo: true, + action: startQuestAction +}, { + type: ActionType.NPC_ACTION, + questAction: { questId: 'cooksAssistant', stage: 'COLLECTING' }, + npcIds: npcIds.lumbridgeCook, + options: 'talk-to', + walkTo: true, + action: handInIngredientsAction +}]); diff --git a/src/plugins/quests/quest-journal-plugin.ts b/src/plugins/quests/quest-journal-plugin.ts new file mode 100644 index 000000000..9448ca208 --- /dev/null +++ b/src/plugins/quests/quest-journal-plugin.ts @@ -0,0 +1,57 @@ +import { buttonAction } from '@server/world/actor/player/action/button-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { widgets } from '@server/world/config/widget'; +import { quests } from '@server/world/config/quests'; +import { wrapText } from '@server/util/strings'; + +export const action: buttonAction = (details) => { + const { player, buttonId } = details; + + const questData = quests[Object.keys(quests).filter(questKey => quests[questKey].questTabId === buttonId)[0]]; + const playerQuest = player.quests.find(quest => quest.questId === questData.id); + let playerStage = 'NOT_STARTED'; + + if(playerQuest && playerQuest.stage) { + playerStage = playerQuest.stage; + } + + let stageText = questData.stages[playerStage]; + let color = 128; + + if(typeof stageText === 'function') { + stageText = stageText(player); + } else if(typeof stageText !== 'string' && typeof stageText === 'object') { + color = stageText.color; + stageText = stageText.text; + } + + let lines; + if(stageText) { + lines = wrapText(stageText as string, 395); + } else { + lines = [ 'Invalid Quest Stage' ]; + } + + player.modifyWidget(widgets.questJournal, { childId: 2, text: '@dre@' + questData.name }); + + for(let i = 0; i <= 100; i++) { + if(i === 0) { + player.modifyWidget(widgets.questJournal, { childId: 3, text: `${lines[0]}` }); + continue; + } + + if(lines.length > i) { + player.modifyWidget(widgets.questJournal, { childId: (i + 4), text: `${lines[i]}` }); + } else { + player.modifyWidget(widgets.questJournal, { childId: (i + 4), text: '' }); + } + } + + player.activeWidget = { + widgetId: widgets.questJournal, + type: 'SCREEN', + closeOnWalk: true + }; +}; + +export default new RunePlugin({ type: ActionType.BUTTON, widgetId: widgets.questTab, action }); diff --git a/src/plugins/skills/crafting/sheep-plugin.ts b/src/plugins/skills/crafting/sheep-plugin.ts new file mode 100644 index 000000000..afd12b2ee --- /dev/null +++ b/src/plugins/skills/crafting/sheep-plugin.ts @@ -0,0 +1,57 @@ +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { npcIds } from '@server/world/config/npc-ids'; +import { npcInitAction } from '@server/world/actor/npc/npc'; +import { World } from '@server/world/world'; +import { itemOnNpcAction } from '@server/world/actor/player/action/item-on-npc-action'; +import { itemIds } from '@server/world/config/item-ids'; +import { soundIds } from '@server/world/config/sound-ids'; +import { animationIds } from '@server/world/config/animation-ids'; + +const initAction: npcInitAction = (details) => { + setInterval(() => { + if (Math.random() >= 0.66) { + details.npc.say(`Baa!`); + details.npc.playSound(soundIds.sheepBaa, 4); + } + }, (Math.floor(Math.random() * 20) + 10) * World.TICK_LENGTH); +}; + +export const shearAction: itemOnNpcAction = (details) => { + details.player.busy = true; + details.player.playAnimation(animationIds.shearSheep); + details.player.playSound(soundIds.shearSheep, 5); + // set to face position, so it does not look weird when the player walk away + details.npc.face(details.player.position); + setTimeout(() => { + if (Math.random() >= 0.66) { + details.player.sendMessage('The sheep manages to get away from you!'); + details.npc.forceMovement(details.player.faceDirection, 5); + } else { + details.player.sendMessage('You get some wool.'); + details.player.giveItem(itemIds.wool); + details.npc.say('Baa!'); + details.npc.playSound(soundIds.sheepBaa, 4); + details.npc.setNewId(npcIds.nakedSheep); + + setTimeout(() => { + details.npc.setNewId(npcIds.sheep); + }, (Math.floor(Math.random() * 20) + 10) * World.TICK_LENGTH); + } + details.player.busy = false; + }, World.TICK_LENGTH); + +}; +export default new RunePlugin([ + { + type: ActionType.NPC_INIT, + npcIds: npcIds.sheep, + action: initAction + }, + { + type: ActionType.ITEM_ON_NPC_ACTION, + npcsIds: [npcIds.sheep], + itemIds: [itemIds.shears, itemIds.recruitmentDrive.shears], + walkTo: true, + action: shearAction + } +]); diff --git a/src/plugins/skills/crafting/spinning-wheel-plugin.ts b/src/plugins/skills/crafting/spinning-wheel-plugin.ts new file mode 100644 index 000000000..2c91394c1 --- /dev/null +++ b/src/plugins/skills/crafting/spinning-wheel-plugin.ts @@ -0,0 +1,197 @@ +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { buttonAction, ButtonActionDetails } from '@server/world/actor/player/action/button-action'; +import { soundIds } from '@server/world/config/sound-ids'; +import { Subscription } from 'rxjs'; +import { itemIds } from '@server/world/config/item-ids'; +import { loopingAction } from '@server/world/actor/player/action/action'; +import { Skill } from '@server/world/actor/skills'; +import { cache } from '@server/game-server'; +import { widgets } from '@server/world/config/widget'; +import { animationIds } from '@server/world/config/animation-ids'; +import { objectIds } from '@server/world/config/object-ids'; + +interface Spinnable { + input: number | number[]; + output: number; + experience: number; + requiredLevel: number; +} + +interface SpinnableButton { + shouldTakeInput: boolean; + count: number; + 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 rootsCbowString: Spinnable = { + input: [ + itemIds.oakRoots, + itemIds.willowRoots, + itemIds.mapleRoots, + itemIds.yewRoots + ], + output: itemIds.crossbowString, + experience: 15, + requiredLevel: 10 +}; +const sinewCbowString: Spinnable = { + input: itemIds.sinew, + output: itemIds.crossbowString, + experience: 15, + requiredLevel: 10 +}; +const magicAmuletString: Spinnable = { + input: itemIds.magicRoots, + output: itemIds.magicString, + experience: 30, + requiredLevel: 19 +}; +const widgetButtonIds: Map = new Map([ + [100, {shouldTakeInput: false, count: 1, spinnable: ballOfWool}], + [99, {shouldTakeInput: false, count: 5, spinnable: ballOfWool}], + [98, {shouldTakeInput: false, count: 10, spinnable: ballOfWool}], + [97, {shouldTakeInput: true, count: 0, spinnable: ballOfWool}], + [95, {shouldTakeInput: false, count: 1, spinnable: bowString}], + [94, {shouldTakeInput: false, count: 5, spinnable: bowString}], + [93, {shouldTakeInput: false, count: 10, spinnable: bowString}], + [91, {shouldTakeInput: true, count: 0, spinnable: bowString}], + [107, {shouldTakeInput: false, count: 1, spinnable: magicAmuletString}], + [106, {shouldTakeInput: false, count: 5, spinnable: magicAmuletString}], + [105, {shouldTakeInput: false, count: 10, spinnable: magicAmuletString}], + [104, {shouldTakeInput: true, count: 0, spinnable: magicAmuletString}], + [121, {shouldTakeInput: false, count: 1, spinnable: rootsCbowString}], + [120, {shouldTakeInput: false, count: 5, spinnable: rootsCbowString}], + [119, {shouldTakeInput: false, count: 10, spinnable: rootsCbowString}], + [118, {shouldTakeInput: true, count: 0, spinnable: rootsCbowString}], + [114, {shouldTakeInput: false, count: 1, spinnable: sinewCbowString}], + [113, {shouldTakeInput: false, count: 5, spinnable: sinewCbowString}], + [112, {shouldTakeInput: false, count: 10, spinnable: sinewCbowString}], + [111, {shouldTakeInput: true, count: 0, spinnable: sinewCbowString}], +]); + +export const openSpinningInterface: objectAction = (details) => { + details.player.activeWidget = { + widgetId: widgets.whatWouldYouLikeToSpin, + type: 'SCREEN', + closeOnWalk: true + }; +}; + +const spinProduct: any = (details: ButtonActionDetails, spinnable: Spinnable, count: number) => { + let elapsedTicks = 0; + + let created = 0; + + // As an multiple items can be used for one of the recipes, check if its an array + let currentItem: number; + let currentItemIndex: number = 0; + let isArray = false; + if (Array.isArray(spinnable.input)) { + isArray = true; + currentItem = spinnable.input[0]; + } else { + currentItem = spinnable.input; + } + // Create a new tick loop + const loop = loopingAction({ player: details.player }); + loop.event.subscribe(() => { + if (created === count) { + loop.cancel(); + return; + } + // Check if out of input material + if (!details.player.hasItemInInventory(currentItem)) { + let cancel = false; + if (isArray) { + if (currentItemIndex < ( spinnable.input).length) { + currentItemIndex++; + currentItem = ( spinnable.input)[currentItemIndex]; + } else { + cancel = true; + } + } else { + cancel = true; + } + if (cancel) { + details.player.sendMessage(`You don't have any ${cache.itemDefinitions.get(currentItem).name.toLowerCase()}.`); + loop.cancel(); + return; + } + } + // Spinning takes 3 ticks for each item + if (elapsedTicks % 3 === 0) { + details.player.removeFirstItem(currentItem); + details.player.giveItem(spinnable.output); + details.player.skills.addExp(Skill.CRAFTING, spinnable.experience); + created++; + } + // animation plays once every two items + if (elapsedTicks % 6 === 0) { + details.player.playAnimation(animationIds.spinSpinningWheel); + details.player.outgoingPackets.playSound(soundIds.spinWool, 5); + } + + elapsedTicks++; + }); +}; + +export const buttonClicked: buttonAction = (details) => { + // Check if player might be spawning widget clientside + if (!details.player.activeWidget || !(details.player.activeWidget.widgetId === 459)) { + return; + } + const product = widgetButtonIds.get(details.buttonId); + + // Close the widget as it is no longer needed + details.player.closeActiveWidgets(); + + if (!details.player.skills.hasSkillLevel(Skill.CRAFTING, product.spinnable.requiredLevel)) { + details.player.sendMessage(`You need a crafting level of ${product.spinnable.requiredLevel} to craft ${cache.itemDefinitions.get(product.spinnable.output).name.toLowerCase()}.`, true); + return; + } + + if (!product.shouldTakeInput) { + // If the player has not chosen make X, we dont need to get input and can just start the crafting + spinProduct(details, product.spinnable, product.count); + } else { + let numericInputSpinSub: Subscription; + let actionCancelledSpinSub: Subscription; + // We should prepare for a number to be sent from the client + 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. + actionCancelledSpinSub = details.player.actionsCancelled.subscribe(() => { + actionCancelledSpinSub.unsubscribe(); + numericInputSpinSub.unsubscribe(); + }); + // Ask the player to enter how many they want to create + details.player.outgoingPackets.showNumberInputDialogue(); + } + + +}; + +export default new RunePlugin([ + { + type: ActionType.OBJECT_ACTION, + objectIds: objectIds.spinningWheel, + options: ['spin'], + walkTo: true, + action: openSpinningInterface + }, + { + type: ActionType.BUTTON, + widgetId: widgets.whatWouldYouLikeToSpin, + buttonIds: Array.from(widgetButtonIds.keys()), + action: buttonClicked + } + ] +); diff --git a/src/plugins/skills/firemaking-plugin.ts b/src/plugins/skills/firemaking-plugin.ts index 4e6cfc127..dbb35820c 100644 --- a/src/plugins/skills/firemaking-plugin.ts +++ b/src/plugins/skills/firemaking-plugin.ts @@ -1,17 +1,21 @@ -import { itemOnItemAction } from '@server/world/mob/player/action/item-on-item-action'; +import { itemOnItemAction } from '@server/world/actor/player/action/item-on-item-action'; import { world } from '@server/game-server'; -import { Skill } from '@server/world/mob/skills'; -import { loopingAction } from '@server/world/mob/player/action/action'; -import { LandscapeObject } from '@runejs/cache-parser'; -import { Player } from '@server/world/mob/player/player'; +import { Skill } from '@server/world/actor/skills'; +import { loopingAction } from '@server/world/actor/player/action/action'; +import { LocationObject } from '@runejs/cache-parser'; +import { Player } from '@server/world/actor/player/player'; import { WorldItem } from '@server/world/items/world-item'; import { Position } from '@server/world/position'; import { randomBetween } from '@server/util/num'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { objectIds } from '@server/world/config/object-ids'; +import { itemIds } from '@server/world/config/item-ids'; +import { soundIds } from '@server/world/config/sound-ids'; +import { animationIds } from '@server/world/config/animation-ids'; const logs = [ { - logId: 1511, + logId: itemIds.logs, requiredLevel: 1, burnExp: 40 } @@ -36,22 +40,18 @@ const fireDuration = (): number => { }; const lightFire = (player: Player, position: Position, worldItemLog: WorldItem, burnExp: number): void => { - world.chunkManager.removeWorldItem(worldItemLog); - const fireObject: LandscapeObject = { - objectId: 2732, + world.removeWorldItem(worldItemLog); + const fireObject: LocationObject = { + objectId: objectIds.fire, x: position.x, y: position.y, level: position.level, type: 10, - rotation: 0 + orientation: 0 }; - world.chunkManager.addTemporaryLandscapeObject(fireObject, position, fireDuration()).then(() => { - world.chunkManager.spawnWorldItem({ itemId: 592, amount: 1 }, position, null, 300); - }); - player.packetSender.playSound(240, 7); player.playAnimation(null); - player.packetSender.chatboxMessage(`The fire catches and the logs begin to burn.`); + player.sendMessage(`The fire catches and the logs begin to burn.`); player.skills.addExp(Skill.FIREMAKING, burnExp); if(!player.walkingQueue.moveIfAble(-1, 0)) { @@ -61,9 +61,13 @@ const lightFire = (player: Player, position: Position, worldItemLog: WorldItem, } } } + world.addTemporaryLocationObject(fireObject, position, fireDuration()).then(() => { + world.spawnWorldItem({ itemId: itemIds.ashes, amount: 1 }, position, null, 300); + }); player.face(position, false); - player.metadata['lastFire'] = Date.now(); + player.metadata.lastFire = Date.now(); + player.metadata.busy = false; }; const action: itemOnItemAction = (details) => { @@ -73,13 +77,13 @@ const action: itemOnItemAction = (details) => { return; } - const log = usedItem.itemId !== 590 ? usedItem : usedWithItem; - const removeFromSlot = usedItem.itemId !== 590 ? usedSlot : usedWithSlot; + const log = usedItem.itemId !== itemIds.tinderbox ? usedItem : usedWithItem; + const removeFromSlot = usedItem.itemId !== itemIds.tinderbox ? usedSlot : usedWithSlot; const skillInfo = logs.find(l => l.logId === log.itemId); const position = player.position; if(!skillInfo) { - player.packetSender.chatboxMessage(`Mishandled firemaking log ${log.itemId}.`); + player.sendMessage(`Mishandled firemaking log ${log.itemId}.`); return; } @@ -87,41 +91,47 @@ const action: itemOnItemAction = (details) => { // @TODO check firemaking level player.removeItem(removeFromSlot); - const worldItemLog = world.chunkManager.spawnWorldItem(log, player.position, player, 300); + const worldItemLog = world.spawnWorldItem(log, player.position, player, 300); if(player.metadata['lastFire'] && Date.now() - player.metadata['lastFire'] < 1200 && canChain(skillInfo.requiredLevel, player.skills.values[Skill.WOODCUTTING].level)) { lightFire(player, position, worldItemLog, skillInfo.burnExp); } else { - player.packetSender.chatboxMessage(`You attempt to light the logs.`); + player.sendMessage(`You attempt to light the logs.`); + let canLightFire = false; let elapsedTicks = 0; - const loop = loopingAction(player); + const loop = loopingAction({ player }); loop.event.subscribe(() => { if(worldItemLog.removed) { loop.cancel(); return; } - // @TODO check for existing location objects again (incase one spawned here during this loop) - // @TODO check for tinderbox incase it was removed + if(canLightFire) { + loop.cancel(); + player.metadata.busy = true; + setTimeout(() => lightFire(player, position, worldItemLog, skillInfo.burnExp), 1200); + return; + } + + // @TODO check for existing location objects again (in-case one spawned here during this loop) + // @TODO check for tinderbox in-case it was removed if(elapsedTicks === 0 || elapsedTicks % 12 === 0) { - player.playAnimation(733); - } - if(elapsedTicks !== 0 && (elapsedTicks === 2 || (elapsedTicks - 2) % 4 === 0)) { - player.packetSender.playSound(375, 7, 1); + player.playAnimation(animationIds.lightingFire); } - const canLightFire = elapsedTicks > 0 && canLight(skillInfo.requiredLevel, player.skills.values[Skill.WOODCUTTING].level); + canLightFire = elapsedTicks > 10 && canLight(skillInfo.requiredLevel, player.skills.values[Skill.WOODCUTTING].level); - if(canLightFire) { - loop.cancel(); - lightFire(player, position, worldItemLog, skillInfo.burnExp); - } else { - elapsedTicks++; + if(!canLightFire && (elapsedTicks === 0 || elapsedTicks % 4 === 0)) { + player.playSound(soundIds.lightingFire, 10, 0); + } else if(canLightFire) { + player.playSound(soundIds.fireLit, 7); } + + elapsedTicks++; }); } }; -export default new RunePlugin({ type: ActionType.ITEM_ON_ITEM, items: [ { item1: 590, item2: 1511 } ], action }); +export default new RunePlugin({ type: ActionType.ITEM_ON_ITEM_ACTION, items: logs.map(log => ({item1: itemIds.tinderbox, item2: log.logId})), action }); diff --git a/src/plugins/skills/level-up-dialogue-plugin.ts b/src/plugins/skills/level-up-dialogue-plugin.ts new file mode 100644 index 000000000..40664e246 --- /dev/null +++ b/src/plugins/skills/level-up-dialogue-plugin.ts @@ -0,0 +1,22 @@ +import { widgetAction } from '@server/world/actor/player/action/widget-action'; +import { ActionType, RunePlugin } from '@server/plugins/plugin'; + +const widgetIds = [ + 158, 161, 175, + 167, 171, 170, + 168, 159, 177, + 165, 164, 163, + 160, 174, 169, + 166, 157, 176, + 173, 162, 172, +]; + +/** + * Handles a level-up dialogue action. + */ +export const action: widgetAction = (details) => { + const { player } = details; + player.closeActiveWidgets(); +}; + +export default new RunePlugin({ type: ActionType.WIDGET_ACTION, widgetIds, action, cancelActions: false }); diff --git a/src/plugins/skills/skill-guide-plugin.ts b/src/plugins/skills/skill-guide-plugin.ts index 25df0ef94..767d46785 100644 --- a/src/plugins/skills/skill-guide-plugin.ts +++ b/src/plugins/skills/skill-guide-plugin.ts @@ -1,8 +1,13 @@ -import { buttonAction } from '@server/world/mob/player/action/button-action'; +import { buttonAction } from '@server/world/actor/player/action/button-action'; import { logger } from '@runejs/logger/dist/logger'; import { JSON_SCHEMA, safeLoad } from 'js-yaml'; import { readFileSync } from 'fs'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; +import { Player } from '@server/world/actor/player/player'; +import { widgetAction } from '@server/world/actor/player/action/widget-action'; +import { widgets } from '@server/world/config/widget'; + +// @TODO fix me! interface SkillSubGuide { name: string; @@ -16,6 +21,7 @@ interface SkillSubGuide { interface SkillGuide { id: number; name: string; + members: boolean; subGuides: SkillSubGuide[]; } @@ -26,7 +32,7 @@ function parseSkillGuides(): SkillGuide[] { const skillGuides = safeLoad(readFileSync('data/config/skill-guides.yaml', 'utf8'), { schema: JSON_SCHEMA }) as SkillGuide[]; if(!skillGuides || skillGuides.length === 0) { - throw 'Unable to read skill guides.'; + throw new Error('Unable to read skill guides.'); } logger.info(`${skillGuides.length} skill guides found.`); @@ -40,81 +46,87 @@ function parseSkillGuides(): SkillGuide[] { const guides = parseSkillGuides(); -const sidebarTextIds = [8846,8823,8824,8827,8837,8840,8843,8859,8862,8865,15303,15306,15309]; -const sidebarIds = [8844,8813,-1,8825,8828,8838,8841,8850,8860,8863,15294,15304,15307]; -const buttonIds = guides.map(g => g.id).concat(sidebarTextIds); - -export const action: buttonAction = (details) => { - let { player, buttonId } = details; - let guide: SkillGuide = guides.find(g => g.id === buttonId); - let subGuideIndex = 0; - let refreshSidebar = true; - - if(!guide) { - const activeSkillGuide = player.metadata['activeSkillGuide']; - if(!activeSkillGuide) { - return; - } - - guide = guides.find(g => g.id === activeSkillGuide); - subGuideIndex = sidebarTextIds.indexOf(buttonId); +const sidebarTextIds = [131, 108, 109, 112, 122, 125, 128, 143, 146, 149, 159, 162, 165]; +const sidebarIds = [129, 98, -1, 110, 113, 123, 126, 134, 144, 147, 150, 160, 163]; +const buttonIds = guides.map(g => g.id); - if(subGuideIndex >= guide.subGuides.length) { - return; - } - - buttonId = activeSkillGuide; - refreshSidebar = false; - } +function loadGuide(player: Player, guideId: number, subGuideId: number = 0, refreshSidebar: boolean = true): void { + const guide: SkillGuide = guides.find(g => g.id === guideId); if(refreshSidebar) { - player.packetSender.updateWidgetString(sidebarTextIds[0], guide.subGuides[0].name); + player.modifyWidget(widgets.skillGuide, { childId: 133, text: (guide.members ? 'Members only skill' : '') }); - for(let i = 1; i < sidebarTextIds.length; i++) { + for(let i = 0; i < sidebarTextIds.length; i++) { const sidebarId = sidebarIds[i]; - let hide: boolean = true; + let hidden: boolean = true; if(i >= guide.subGuides.length) { - player.packetSender.updateWidgetString(sidebarTextIds[i], ''); - hide = true; + player.modifyWidget(widgets.skillGuide, { childId: sidebarTextIds[i], text: '' }); + hidden = true; } else { - player.packetSender.updateWidgetString(sidebarTextIds[i], guide.subGuides[i].name); - hide = false; + player.modifyWidget(widgets.skillGuide, { childId: sidebarTextIds[i], text: guide.subGuides[i].name }); + hidden = false; } if(sidebarId !== -1) { // Apparently you can never have only TWO subguides... - // Because 8813 deletes both options 2 AND 3. So, good thing there are no guides with only 2 sections, I guess?... + // Because childId 98 deletes both options 2 AND 3. So, good thing there are no guides with only 2 sections, I guess?... // Verified this in an interface editor, and they are indeed grouped in a single layer for some reason... - player.packetSender.toggleWidgetVisibility(sidebarIds[i] as number, hide); + player.modifyWidget(widgets.skillGuide, { childId: sidebarIds[i], hidden }); } } } - const subGuide: SkillSubGuide = guide.subGuides[subGuideIndex]; + const subGuide: SkillSubGuide = guide.subGuides[subGuideId]; - const itemIds: number[] = subGuide.lines.map(g => g.itemId).concat(new Array(30 - subGuide.lines.length).fill(null)); - player.packetSender.sendUpdateAllWidgetItemsById(8847, itemIds); + player.modifyWidget(widgets.skillGuide, { childId: 1, text: (guide.name + ' - ' + subGuide.name) }); - player.packetSender.updateWidgetString(8716, guide.name + ' Guide'); - player.packetSender.updateWidgetString(8849, subGuide.name); + const itemIds: number[] = subGuide.lines.map(g => g.itemId).concat(new Array(30 - subGuide.lines.length).fill(null)); + player.outgoingPackets.sendUpdateAllWidgetItemsById({ widgetId: widgets.skillGuide, containerId: 132 }, itemIds); for(let i = 0; i < 30; i++) { if(subGuide.lines.length <= i) { - player.packetSender.updateWidgetString(8720 + i, ''); - player.packetSender.updateWidgetString(8760 + i, ''); + player.modifyWidget(widgets.skillGuide, { childId: 5 + i, text: '' }); + player.modifyWidget(widgets.skillGuide, { childId: 45 + i, text: '' }); } else { - player.packetSender.updateWidgetString(8720 + i, subGuide.lines[i].level.toString()); - player.packetSender.updateWidgetString(8760 + i, subGuide.lines[i].text); + player.modifyWidget(widgets.skillGuide, { childId: 5 + i, text: subGuide.lines[i].level.toString() }); + player.modifyWidget(widgets.skillGuide, { childId: 45 + i, text: subGuide.lines[i].text }); } } player.activeWidget = { - widgetId: 8714, + widgetId: widgets.skillGuide, type: 'SCREEN', closeOnWalk: false }; - player.metadata['activeSkillGuide'] = buttonId; + player.metadata['activeSkillGuide'] = guideId; +} + +export const openGuideAction: buttonAction = (details) => { + const { player, buttonId } = details; + loadGuide(player, buttonId); +}; + +export const openSubGuideAction: widgetAction = (details) => { + const { player, childId } = details; + + const activeSkillGuide = player.metadata['activeSkillGuide']; + + if(!activeSkillGuide) { + return; + } + + const guide = guides.find(g => g.id === activeSkillGuide); + const subGuideId = sidebarTextIds.indexOf(childId); + + if(subGuideId >= guide.subGuides.length) { + return; + } + + loadGuide(player, guide.id, subGuideId, false); }; -export default new RunePlugin({ type: ActionType.BUTTON, buttonIds, action }); +export default new RunePlugin([ + { type: ActionType.BUTTON, widgetId: widgets.skillsTab, buttonIds, action: openGuideAction }, + { type: ActionType.WIDGET_ACTION, widgetIds: widgets.skillGuide, childIds: sidebarTextIds, optionId: 0, action: openSubGuideAction } +]); diff --git a/src/plugins/skills/woodcutting-plugin.ts b/src/plugins/skills/woodcutting-plugin.ts index 0ceeae9f3..856a321b6 100644 --- a/src/plugins/skills/woodcutting-plugin.ts +++ b/src/plugins/skills/woodcutting-plugin.ts @@ -1,16 +1,16 @@ -import { objectAction } from '@server/world/mob/player/action/object-action'; -import { loopingAction } from '@server/world/mob/player/action/action'; +import { objectAction } from '@server/world/actor/player/action/object-action'; +import { loopingAction } from '@server/world/actor/player/action/action'; import { ActionType, RunePlugin } from '@server/plugins/plugin'; const cycle = (player, i) => { - player.packetSender.chatboxMessage(`i = ${i}`); + player.sendMessage(`i = ${i}`); return i < 10; }; export const action: objectAction = (details) => { let i = 0; - const loop = loopingAction(details.player); + const loop = loopingAction({ player: details.player }); loop.event.subscribe(() => { if(!cycle(details.player, i++)) { loop.cancel(); diff --git a/src/task/task.ts b/src/task/task.ts index bdfbe8995..b3888d600 100644 --- a/src/task/task.ts +++ b/src/task/task.ts @@ -1,5 +1,12 @@ +import { timer } from 'rxjs'; +import { World } from '@server/world/world'; + export abstract class Task { public abstract async execute(): Promise; } + +export const schedule = async (ticks: number): Promise => { + return timer(ticks * World.TICK_LENGTH).toPromise(); +}; diff --git a/src/util/colors.ts b/src/util/colors.ts new file mode 100644 index 000000000..b0d69c9cd --- /dev/null +++ b/src/util/colors.ts @@ -0,0 +1,17 @@ +export function hexToRgb(hex: number): { r: number, b: number, g: number } { + return { + r: (hex >> 16) & 0xff, + g: (hex >> 8) & 0xff, + b: hex & 0xff + }; +} + +export function rgbTo16Bit(r: number, g: number, b: number): number { + return ((r & 0x1f) << 11) | ((g & 0x3f) << 5) | (b & 0x1f) << 0; +} + +export const colors = { + green: 0x00ff00, + yellow: 0xffff00, + red: 0xff0000 +}; diff --git a/src/util/strings.ts b/src/util/strings.ts new file mode 100644 index 000000000..921fff832 --- /dev/null +++ b/src/util/strings.ts @@ -0,0 +1,80 @@ +export const startsWithVowel = (str: string): boolean => { + str = str.trim().toLowerCase(); + + const firstChar = str.charAt(0); + + return (firstChar === 'a' || firstChar === 'e' || firstChar === 'i' || firstChar === 'o' || firstChar === 'u'); +}; + +// Thank you to the Apollo team for these values. :) +const charWidths = [ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 7, 14, 9, 12, 12, 4, 5, + 5, 10, 8, 4, 8, 4, 7, 9, 7, 9, 8, 8, 8, 9, 7, 9, 9, 4, 5, 7, + 9, 7, 9, 14, 9, 8, 8, 8, 7, 7, 9, 8, 6, 8, 8, 7, 10, 9, 9, 8, + 9, 8, 8, 6, 9, 8, 10, 8, 8, 8, 6, 7, 6, 9, 10, 5, 8, 8, 7, 8, + 8, 7, 8, 8, 4, 7, 7, 4, 10, 8, 8, 8, 8, 6, 8, 6, 8, 8, 9, 8, + 8, 8, 6, 4, 6, 12, 3, 10, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 4, 8, 11, 8, 8, 4, 8, 7, 12, 6, 7, 9, 5, 12, 5, 6, 10, 6, 6, 6, + 8, 8, 4, 5, 5, 6, 7, 11, 11, 11, 9, 9, 9, 9, 9, 9, 9, 13, 8, 8, + 8, 8, 8, 4, 4, 5, 4, 8, 9, 9, 9, 9, 9, 9, 8, 10, 9, 9, 9, 9, + 8, 8, 8, 8, 8, 8, 8, 8, 8, 13, 6, 8, 8, 8, 8, 4, 4, 5, 4, 8, + 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8 ]; + +export function wrapText(text: string, maxWidth: number): string[] { + const lines = []; + + let lineStartIdx = 0; + let width = 0; + let lastSpace = 0; + let widthAfterSpace = 0; + let lastSpaceChar = ''; + + for(let i = 0; i < text.length; i++) { + const char = text.charAt(i); + + // Ignore and strings... + if(char === '<' && (text.charAt(i + 1) === '/' || text.charAt(i + 1) === 'c' && text.charAt(i + 2) === 'o' && text.charAt(i + 3) === 'l')) { + const tagCloseIndex = text.indexOf('>', i); + i = tagCloseIndex; + continue; + } + + const charWidth = charWidths[text.charCodeAt(i)]; + width += charWidth; + widthAfterSpace += charWidth; + + if(char === ' ' || char === '\n' || char === '-') { + lastSpaceChar = char; + lastSpace = i; + widthAfterSpace = 0; + } + + if(width >= maxWidth || char === '\n') { + lines.push(text.substring(lineStartIdx, lastSpaceChar === '-' ? lastSpace + 1 : lastSpace)); + lineStartIdx = lastSpace + 1; + width = widthAfterSpace; + } + } + + if(lineStartIdx !== text.length - 1) { + lines.push(text.substring(lineStartIdx, text.length)); + } + + return lines; +} + +export function stringToLong(s: string): bigint { + let l: bigint = BigInt(0); + + for(let i = 0; i < s.length && i < 12; i++) { + const c = s.charAt(i); + const cc = s.charCodeAt(i); + l *= BigInt(37); + if(c >= 'A' && c <= 'Z') l += BigInt((1 + cc) - 65); + else if(c >= 'a' && c <= 'z') l += BigInt((1 + cc) - 97); + else if(c >= '0' && c <= '9') l += BigInt((27 + cc) - 48); + } + while(l % BigInt(37) == BigInt(0) && l != BigInt(0)) l /= BigInt(37); + return l; +} diff --git a/src/util/time.ts b/src/util/time.ts index 9570640e2..af71167bb 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -2,3 +2,11 @@ export const rsTime = (date: Date): number => { const days = Math.round(date.getTime() / 0x5265c00); return days - 11745; }; + +export const daysSinceLastLogin = (lastLogin: Date): number => { + if(!lastLogin) { + return -1; + } + + return Math.floor(Math.abs(new Date().valueOf() - lastLogin.valueOf()) / (1000 * 60 * 60 * 24)); +}; diff --git a/src/web-server.ts b/src/web-server.ts index e77501da0..bddbf50d9 100644 --- a/src/web-server.ts +++ b/src/web-server.ts @@ -1,6 +1,6 @@ import express, { Request } from 'express'; -import { gameCache, world } from './game-server'; -import { Player } from './world/mob/player/player'; +import { cache, world } from './game-server'; +import { Player } from './world/actor/player/player'; import { constants } from 'http2'; import { logger } from '@runejs/logger'; import { ItemData, ItemDetails, saveItemData } from './world/config/item-data'; @@ -153,7 +153,7 @@ export function runWebServer(): void { } const itemData = req.body as ItemData; - const itemDefinition = gameCache.itemDefinitions.get(itemId); + const itemDefinition = cache.itemDefinitions.get(itemId); const itemDetails = { ...itemDefinition, ...itemData } as ItemDetails; world.itemData.set(itemId, itemDetails); saveItemData(world.itemData); @@ -188,9 +188,9 @@ export function runWebServer(): void { const noted = req.query.noted.toLowerCase().trim(); if(noted === 'false') { - worldItemList = worldItemList.filter(itemData => itemData.notedVersionOf === -1); + worldItemList = worldItemList.filter(itemData => itemData.noteTemplateId === -1); } else if(noted === 'true') { - worldItemList = worldItemList.filter(itemData => itemData.notedVersionOf !== -1); + worldItemList = worldItemList.filter(itemData => itemData.noteTemplateId !== -1); } } } diff --git a/src/world/mob/mob.ts b/src/world/actor/actor.ts similarity index 65% rename from src/world/mob/mob.ts rename to src/world/actor/actor.ts index 66ad15935..0842c5bad 100644 --- a/src/world/mob/mob.ts +++ b/src/world/actor/actor.ts @@ -3,14 +3,16 @@ import { ItemContainer } from '../items/item-container'; import { Animation, Graphic, UpdateFlags } from './update-flags'; import { Npc } from './npc/npc'; import { Entity } from '../entity'; -import { Skills } from '@server/world/mob/skills'; +import { Skills } from '@server/world/actor/skills'; import { Item } from '@server/world/items/item'; import { Position } from '@server/world/position'; +import { DirectionData, directionFromIndex } from '@server/world/direction'; +import { CombatAction } from '@server/world/actor/player/action/combat-action'; /** - * Handles a mobile entity within the game world. + * Handles an actor within the game world. */ -export abstract class Mob extends Entity { +export abstract class Actor extends Entity { private _worldIndex: number; public readonly updateFlags: UpdateFlags; @@ -20,7 +22,9 @@ export abstract class Mob extends Entity { private _faceDirection: number; private readonly _inventory: ItemContainer; public readonly skills: Skills; + private _busy: boolean; public readonly metadata: { [key: string]: any } = {}; + private _combatActions: CombatAction[]; protected constructor() { super(); @@ -28,21 +32,23 @@ export abstract class Mob extends Entity { this._walkingQueue = new WalkingQueue(this); this._walkDirection = -1; this._runDirection = -1; - this._faceDirection = -1; + this._faceDirection = 6; this._inventory = new ItemContainer(28); this.skills = new Skills(this); + this._busy = false; + this._combatActions = []; } - public face(face: Position | Mob, clearWalkingQueue: boolean = true, autoClear: boolean = true): void { + public face(face: Position | Actor, clearWalkingQueue: boolean = true, autoClear: boolean = true): void { if(face instanceof Position) { this.updateFlags.facePosition = face; - } else if(face instanceof Mob) { - this.updateFlags.faceMob = face; - this.metadata['faceMob'] = face; + } else if(face instanceof Actor) { + this.updateFlags.faceActor = face; + this.metadata['faceActor'] = face; if(autoClear) { setTimeout(() => { - this.clearFaceMob(); + this.clearFaceActor(); }, 20000); } } @@ -53,16 +59,16 @@ export abstract class Mob extends Entity { } } - public clearFaceMob(): void { - if(this.metadata['faceMob']) { - this.updateFlags.faceMob = null; - this.metadata['faceMob'] = undefined; + public clearFaceActor(): void { + if(this.metadata['faceActor']) { + this.updateFlags.faceActor = null; + this.metadata['faceActor'] = undefined; } } public playAnimation(animation: number | Animation): void { if(typeof animation === 'number') { - animation = { id: animation, delay: 0 }; + animation = {id: animation, delay: 0}; } this.updateFlags.animation = animation; @@ -70,7 +76,7 @@ export abstract class Mob extends Entity { public playGraphics(graphics: number | Graphic): void { if(typeof graphics === 'number') { - graphics = { id: graphics, delay: 0, height: 120 }; + graphics = {id: graphics, delay: 0, height: 120}; } this.updateFlags.graphics = graphics; @@ -93,7 +99,7 @@ export abstract class Mob extends Entity { } public canMove(): boolean { - return true; + return !this.busy; } public initiateRandomMovement(): void { @@ -162,7 +168,50 @@ export abstract class Mob extends Entity { }, 1000); } - public abstract equals(mob: Mob): boolean; + public forceMovement(direction: number, steps: number): void { + if(!this.canMove()) { + return; + } + + let px: number; + let py: number; + let movementAllowed = false; + + while(!movementAllowed) { + px = this.position.x; + py = this.position.y; + + const movementDirection: DirectionData = directionFromIndex(direction); + if(!movementDirection) { + return; + } + let valid = true; + for(let step = 0; step < steps; step++) { + px += movementDirection.deltaX; + py += movementDirection.deltaY; + + if(this instanceof Npc) { + if(px > this.initialPosition.x + this.movementRadius || px < this.initialPosition.x - this.movementRadius + || py > this.initialPosition.y + this.movementRadius || py < this.initialPosition.y - this.movementRadius) { + valid = false; + } + } + + } + + movementAllowed = valid; + + + } + + if(px !== this.position.x || py !== this.position.y) { + this.walkingQueue.clear(); + this.walkingQueue.valid = true; + this.walkingQueue.add(px, py); + } + } + + public abstract equals(actor: Actor): boolean; public get worldIndex(): number { return this._worldIndex; @@ -203,4 +252,16 @@ export abstract class Mob extends Entity { public get inventory(): ItemContainer { return this._inventory; } + + public get busy(): boolean { + return this._busy; + } + + public set busy(value: boolean) { + this._busy = value; + } + + public get combatActions(): CombatAction[] { + return this._combatActions; + } } diff --git a/src/world/actor/dialogue.ts b/src/world/actor/dialogue.ts new file mode 100644 index 000000000..74a1b103d --- /dev/null +++ b/src/world/actor/dialogue.ts @@ -0,0 +1,538 @@ +import { Npc } from '@server/world/actor/npc/npc'; +import { Player } from '@server/world/actor/player/player'; +import { Subscription } from 'rxjs'; +import { cache } from '@server/game-server'; +import { logger } from '@runejs/logger/dist/logger'; +import _ from 'lodash'; +import { wrapText } from '@server/util/strings'; +import { ActionsCancelledWarning, WidgetsClosedWarning } from '@server/error-handling'; + +export enum Emote { + POMPOUS = 'POMPOUS', + UNKOWN_CREATURE = 'UNKOWN_CREATURE', + VERY_SAD = 'VERY_SAD', + HAPPY = 'HAPPY', + SHOCKED = 'SHOCKED', + WONDERING = 'WONDERING', + GOBLIN = 'GOBLIN', + TREE = 'TREE', + GENERIC = 'GENERIC', + SKEPTICAL = 'SKEPTICAL', + WORRIED = 'WORRIED', + DROWZY = 'DROWZY', + LAUGH = 'LAUGH', + SAD = 'SAD', + ANGRY = 'ANGRY', + EASTER_BUNNY = 'EASTER_BUNNY', + + BLANK_STARE = 'BLANK_STARE', + SINGLE_WORD = 'SINGLE_WORD', + EVIL_STARE = 'EVIL_STARE', + LAUGH_EVIL = 'LAUGH_EVIL' +} + +// A big thanks to Dust R I P for all these emotes! +enum EmoteAnimation { + POMPOUS_1LINE = 554, + POMPOUS_2LINE = 555, + POMPOUS_3LINE = 556, + POMPOUS_4LINE = 557, + UNKOWN_CREATURE_1LINE = 558, + UNKOWN_CREATURE_2LINE = 559, + UNKOWN_CREATURE_3LINE = 560, + UNKOWN_CREATURE_4LINE = 561, + VERY_SAD1LINE = 562, + VERY_SAD2LINE = 563, + VERY_SAD3LINE = 564, + VERY_SAD4LINE = 565, + SINGLE_WORD = 566, + HAPPY_1LINE = 567, + HAPPY_2LINE = 568, + HAPPY_3LINE = 569, + HAPPY_4LINE = 570, + SHOCKED_1LINE = 571, + SHOCKED_2LINE = 572, + SHOCKED_3LINE = 573, + SHOCKED_4LINE = 574, + WONDERING_1LINE = 575, + WONDERING_2LINE = 576, + WONDERING_3LINE = 577, + WONDERING_4LINE = 578, + BLANK_STARE = 579, + GOBLIN_1LINE = 580, + GOBLIN_2LINE = 581, + GOBLIN_3LINE = 582, + GOBLIN_4LINE = 583, + TREE_1LINE = 584, + TREE_2LINE = 585, + TREE_3LINE = 586, + TREE_4LINE = 587, + GENERIC_1LINE = 588, + GENERIC_2LINE = 589, + GENERIC_3LINE = 590, + GENERIC_4LINE = 591, + SKEPTICAL_1LINE = 592, + SKEPTICAL_2LINE = 593, + SKEPTICAL_3LINE = 594, + SKEPTICAL_4LINE = 595, + WORRIED_1LINE = 596, + WORRIED_2LINE = 597, + WORRIED_3LINE = 598, + WORRIED_4LINE = 599, + DROWZY_1LINE = 600, + DROWZY_2LINE = 601, + DROWZY_3LINE = 602, + DROWZY_4LINE = 603, + EVIL_STARE = 604, + LAUGH_1LINE = 605, + LAUGH_2LINE = 606, + LAUGH_3LINE = 607, + LAUGH_4LINE = 608, + LAUGH_EVIL = 609, + SAD_1LINE = 610, + SAD_2LINE = 611, + SAD_3LINE = 612, + SAD_4LINE = 613, + ANGRY_1LINE = 614, + ANGRY_2LINE = 615, + ANGRY_3LINE = 616, + ANGRY_4LINE = 617, + EASTER_BUNNY_1LINE = 1824, + EASTER_BUNNY_2LINE = 1825, + EASTER_BUNNY_3LINE = 1826, + EASTER_BUNNY_4LINE = 1827, +} + +const nonLineEmotes = [ Emote.BLANK_STARE, Emote.SINGLE_WORD, Emote.EVIL_STARE, Emote.LAUGH_EVIL ]; +const playerWidgetIds = [ 64, 65, 66, 67 ]; +const npcWidgetIds = [ 241, 242, 243, 244 ]; +const optionWidgetIds = [ 228, 230, 232, 234 ]; +const textWidgetIds = [ 210, 211, 212, 213, 214 ]; + +function wrapDialogueText(text: string, type: 'ACTOR' | 'TEXT'): string[] { + return wrapText(text, type === 'ACTOR' ? 340 : 430); +} + +function parseDialogueFunctionArgs(func: Function): string[] { + const str = func.toString(); + + if(!str) { + return null; + } + + const argEndIndex = str.indexOf('=>'); + + if(argEndIndex === -1) { + return null; + } + + const arg = str.substring(0, argEndIndex).replace(/[\\(\\) ]/g, '').trim(); + if(!arg || arg.length === 0) { + return null; + } + + return arg.split(','); +} + +export type DialogueTree = (Function | DialogueFunction | GoToAction)[]; + +interface NpcParticipant { + npc: Npc | number; + key: string; +} + +class DialogueFunction { + constructor(public type: string, public execute: Function) {} +} + +export const execute = (execute: Function): DialogueFunction => new DialogueFunction('execute', execute); +export const goto = (to: string | Function): GoToAction => new GoToAction(to); + +type ParsedDialogueTree = (DialogueAction | DialogueFunction | string)[]; + +interface DialogueAction { + tag: string; + type: string; +} + +class GoToAction implements DialogueAction { + public tag: string; + public type = 'GOTO'; + + constructor(public to: string | Function) { + } +} + +interface ActorDialogueAction extends DialogueAction { + animation: number; + lines: string[]; +} + +interface NpcDialogueAction extends ActorDialogueAction { + npcId: number; +} + +interface PlayerDialogueAction extends ActorDialogueAction { + player: Player; +} + +interface TextDialogueAction extends DialogueAction { + lines: string[]; +} + +interface OptionsDialogueAction extends DialogueAction { + options: { [key: string]: ParsedDialogueTree }; +} + +interface SubDialogueTreeAction extends DialogueAction { + subTree: DialogueTree; + npcParticipants?: NpcParticipant[]; +} + +function parseDialogueTree(player: Player, npcParticipants: NpcParticipant[], dialogueTree: DialogueTree): ParsedDialogueTree { + const parsedDialogueTree: ParsedDialogueTree = []; + + for(let i = 0; i < dialogueTree.length; i++) { + const dialogueAction = dialogueTree[i]; + + if(dialogueAction instanceof DialogueFunction) { + // Code execution dialogue. + parsedDialogueTree.push(dialogueAction as DialogueFunction); + continue; + } + + if(dialogueAction instanceof GoToAction) { + parsedDialogueTree.push(dialogueAction); + continue; + } + + let args = parseDialogueFunctionArgs(dialogueAction); + if(args === null) { + args = ['()']; + } + const dialogueType = args[0]; + let tag: string = null; + + if(args.length === 2 && typeof args[1] === 'string') { + player.metadata.dialogueIndices[args[1]] = i; + tag = args[1]; + } + + if(!dialogueType) { + logger.error('No arguments passed to dialogue function.'); + continue; + } + + let isOptions = false; + + if(dialogueType === 'options' || dialogueType === '()') { + // Options or custom function dialogue. + + let result = dialogueAction(); + + if(dialogueType === '()') { + const funcResult = result(); + + if(!Array.isArray(funcResult) || funcResult.length === 0) { + logger.error('Invalid dialogue function response type.'); + continue; + } + + if(typeof funcResult[0] === 'function') { + // given function returned a dialogue tree + parsedDialogueTree.push(...parseDialogueTree(player, npcParticipants, funcResult)); + } else { + // given function returned an option list + result = funcResult; + isOptions = true; + } + } else { + isOptions = true; + } + + if(isOptions) { + const options = (result as any[]).filter((option, index) => index % 2 === 0); + const trees = (result as any[]).filter((option, index) => index % 2 !== 0); + const optionsDialogueAction: OptionsDialogueAction = { + options: {}, + tag, type: 'OPTIONS' + }; + + for(let j = 0; j < options.length; j++) { + const option = options[j]; + const tree = parseDialogueTree(player, npcParticipants, trees[j]); + optionsDialogueAction.options[option] = tree; + } + + parsedDialogueTree.push(optionsDialogueAction); + } + } else if(dialogueType === 'text') { + // Text-only dialogue. + + const text: string = dialogueAction(); + const lines = wrapDialogueText(text, 'TEXT'); + parsedDialogueTree.push({ lines, tag, type: 'TEXT' } as TextDialogueAction); + } else if(dialogueType === 'subtree') { + // Dialogue sub-tree. + + const subTree: DialogueTree = dialogueAction(); + parsedDialogueTree.push({ tag, type: 'SUBTREE', subTree, npcParticipants } as SubDialogueTreeAction); + } else { + // Player or Npc dialogue. + + let dialogueDetails: [ Emote, string ]; + let npc: Npc | number; + + if(dialogueType !== 'player') { + const participant = npcParticipants.find(p => p.key === dialogueType) as NpcParticipant; + if(!participant || !participant.npc) { + logger.error('No matching npc found for npc dialogue action.'); + continue; + } + + npc = participant.npc; + if(typeof npc !== 'number') { + npc = npc.id; + } + + dialogueDetails = dialogueAction(npc); + } else { + dialogueDetails = dialogueAction(player); + } + + const emote = dialogueDetails[0] as Emote; + const text = dialogueDetails[1] as string; + const lines = wrapDialogueText(text, 'ACTOR'); + const animation = nonLineEmotes.indexOf(emote) !== -1 ? EmoteAnimation[emote] : EmoteAnimation[`${emote}_${lines.length}LINE`]; + + if(dialogueType !== 'player') { + const npcDialogueAction: NpcDialogueAction = { + npcId: npc as number, animation, lines, tag, type: 'NPC' + }; + + parsedDialogueTree.push(npcDialogueAction); + } else { + const playerDialogueAction: PlayerDialogueAction = { + player, animation, lines, tag, type: 'PLAYER' + }; + + parsedDialogueTree.push(playerDialogueAction); + } + } + } + + return parsedDialogueTree; +} + +async function runParsedDialogue(player: Player, dialogueTree: ParsedDialogueTree, tag?: string): Promise { + let stopLoop = false; + + for(let i = 0; i < dialogueTree.length; i++) { + if(stopLoop) { + throw new ActionsCancelledWarning(); + } + + const sub: Subscription[] = []; + + await new Promise((resolve, reject) => { + let dialogueAction = dialogueTree[i]; + + if(dialogueAction instanceof DialogueFunction && !tag) { + // Code execution dialogue. + dialogueAction.execute(); + resolve(); + return; + } + + dialogueAction = dialogueAction as DialogueAction; + + if(dialogueAction.type === 'GOTO' && !tag) { + // Goto dialogue. + const goToAction = (dialogueAction as GoToAction); + if(typeof goToAction.to === 'function') { + const goto: string = goToAction.to(); + runParsedDialogue(player, player.metadata.dialogueTree, goto).then(() => resolve()); + } else { + runParsedDialogue(player, player.metadata.dialogueTree, goToAction.to).then(() => resolve()); + } + return; + } + + let widgetId: number; + let isOptions = false; + + if(dialogueAction.type === 'OPTIONS') { + // Option dialogue. + const optionsAction = dialogueAction as OptionsDialogueAction; + isOptions = true; + const options = Object.keys(optionsAction.options); + const trees = options.map(option => optionsAction.options[option]); + + if(tag === undefined || dialogueAction.tag === tag) { + tag = undefined; + + widgetId = optionWidgetIds[options.length - 2]; + + for(let i = 0; i < options.length; i++) { + player.outgoingPackets.updateWidgetString(widgetId, 1 + i, options[i]); + } + + sub.push(player.dialogueInteractionEvent.subscribe(choice => { + sub.forEach(s => s.unsubscribe()); + const tree: ParsedDialogueTree = trees[choice - 1]; + if(!tree || tree.length === 0) { + resolve(); + } else { + runParsedDialogue(player, tree, tag).then(() => resolve()); + } + })); + } else if(tag !== undefined) { + for(let i = 0; i < options.length; i++) { + const tree = trees[i]; + const didRun = runParsedDialogue(player, tree, tag); + if(didRun) { + resolve(); + } + } + } + } else if(dialogueAction.type === 'TEXT') { + // Text-only dialogue. + + if(tag === undefined || dialogueAction.tag === tag) { + tag = undefined; + + const textDialogueAction = dialogueAction as TextDialogueAction; + const lines = textDialogueAction.lines; + + if(lines.length > 5) { + throw new Error(`Too many lines for text dialogue! Dialogue has ${lines.length} lines but ` + + `the maximum is 5: ${JSON.stringify(lines)}`); + } + + widgetId = textWidgetIds[lines.length - 1]; + + for(let i = 0; i < lines.length; i++) { + player.outgoingPackets.updateWidgetString(widgetId, i, lines[i]); + } + } else if(tag !== undefined) { + resolve(); + } + } else if(dialogueAction.type === 'SUBTREE') { + // Dialogue sub-tree. + + const action = (dialogueAction as SubDialogueTreeAction); + + if(dialogueAction.tag === tag) { + const originalIndices = _.cloneDeep(player.metadata.dialogueIndices || {}); + const originalTree = _.cloneDeep(player.metadata.dialogueTree || []); + player.metadata.dialogueIndices = {}; + const parsedSubTree = parseDialogueTree(player, action.npcParticipants, action.subTree); + player.metadata.dialogueTree = parsedSubTree; + runParsedDialogue(player, parsedSubTree).then(() => { + player.metadata.dialogueIndices = originalIndices; + player.metadata.dialogueTree = originalTree; + resolve(); + }); + } else if(tag && dialogueAction.tag !== tag) { + const originalIndices = _.cloneDeep(player.metadata.dialogueIndices || {}); + const originalTree = _.cloneDeep(player.metadata.dialogueTree || []); + player.metadata.dialogueIndices = {}; + const parsedSubTree = parseDialogueTree(player, action.npcParticipants, action.subTree); + player.metadata.dialogueTree = parsedSubTree; + runParsedDialogue(player, parsedSubTree, tag).then(() => { + player.metadata.dialogueIndices = originalIndices; + player.metadata.dialogueTree = originalTree; + resolve(); + }); + } else { + resolve(); + } + } else { + // Player or Npc dialogue. + + if(tag === undefined || dialogueAction.tag === tag) { + tag = undefined; + + let npcId: number; + + if(dialogueAction.type === 'NPC') { + npcId = (dialogueAction as NpcDialogueAction).npcId; + } + + const actorDialogueAction = dialogueAction as ActorDialogueAction; + const lines = actorDialogueAction.lines; + + if(lines.length > 4) { + throw new Error(`Too many lines for actor dialogue! Dialogue has ${lines.length} lines but ` + + `the maximum is 4: ${JSON.stringify(lines)}`); + } + + const animation = actorDialogueAction.animation; + + if(dialogueAction.type === 'NPC') { + widgetId = npcWidgetIds[lines.length - 1]; + player.outgoingPackets.setWidgetNpcHead(widgetId, 0, npcId as number); + player.outgoingPackets.updateWidgetString(widgetId, 1, cache.npcDefinitions.get(npcId as number).name); + } else { + widgetId = playerWidgetIds[lines.length - 1]; + player.outgoingPackets.setWidgetPlayerHead(widgetId, 0); + player.outgoingPackets.updateWidgetString(widgetId, 1, player.username); + } + + player.outgoingPackets.playWidgetAnimation(widgetId, 0, animation); + + for(let i = 0; i < lines.length; i++) { + player.outgoingPackets.updateWidgetString(widgetId, 2 + i, lines[i]); + } + } else if(tag !== undefined) { + resolve(); + } + } + + if(tag === undefined && widgetId) { + if(!isOptions) { + sub.push(player.dialogueInteractionEvent.subscribe(() => { + sub.forEach(s => s.unsubscribe()); + resolve(); + })); + } + + player.activeWidget = { + widgetId: widgetId, + type: 'CHAT', + closeOnWalk: true, + forceClosed: () => reject(new WidgetsClosedWarning()) + }; + } + }).then(() => { + sub.forEach(s => s.unsubscribe()); + }).catch(error => { + sub.forEach(s => s.unsubscribe()); + stopLoop = true; + + if(!(error instanceof ActionsCancelledWarning) && !(error instanceof WidgetsClosedWarning)) { + throw error; + } + }); + } + + return tag === undefined; +} + +export async function dialogue(participants: (Player | NpcParticipant)[], dialogueTree: DialogueTree): Promise { + const player = participants.find(p => p instanceof Player) as Player; + + if(!player) { + throw new Error('Player instance not provided to dialogue action.'); + } + + let npcParticipants = participants.filter(p => !(p instanceof Player)) as NpcParticipant[]; + if(!npcParticipants) { + npcParticipants = []; + } + + player.metadata.dialogueIndices = {}; + const parsedDialogueTree = parseDialogueTree(player, npcParticipants, dialogueTree); + player.metadata.dialogueTree = parsedDialogueTree; + await runParsedDialogue(player, parsedDialogueTree); +} diff --git a/src/world/mob/npc/npc.ts b/src/world/actor/npc/npc.ts similarity index 54% rename from src/world/mob/npc/npc.ts rename to src/world/actor/npc/npc.ts index 4f19aae63..78b61d7d1 100644 --- a/src/world/mob/npc/npc.ts +++ b/src/world/actor/npc/npc.ts @@ -1,11 +1,13 @@ -import { Mob } from '@server/world/mob/mob'; +import { Actor } from '@server/world/actor/actor'; import { NpcSpawn } from '@server/world/config/npc-spawn'; import { NpcDefinition } from '@runejs/cache-parser'; import uuidv4 from 'uuid/v4'; import { Position } from '@server/world/position'; import { world } from '@server/game-server'; -import { Direction } from '@server/world/direction'; +import { Direction, directionData } from '@server/world/direction'; import { QuadtreeKey } from '@server/world/world'; +import { ActionPlugin } from '@server/plugins/plugin'; +import { basicNumberFilter } from '@server/plugins/plugin-loader'; interface NpcAnimations { walk: number; @@ -15,21 +17,36 @@ interface NpcAnimations { turnLeft: number; } +let npcInitPlugins: NpcInitPlugin[]; + +export type npcInitAction = (details: { npc: Npc }) => void; + +export const setNpcInitPlugins = (plugins: ActionPlugin[]): void => { + npcInitPlugins = plugins as NpcInitPlugin[]; +}; + +export interface NpcInitPlugin extends ActionPlugin { + // The action function to be performed. + action: npcInitAction; + // A single NPC ID or a list of NPC IDs that this action applies to. + npcIds: number | number[]; +} + /** * Represents a non-player character within the game world. */ -export class Npc extends Mob { +export class Npc extends Actor { - public readonly id: number; + public id: number; public readonly uuid: string; private _name: string; private _combatLevel: number; private _animations: NpcAnimations; public readonly options: string[]; private _movementRadius: number = 0; - private _initialFaceDirection: Direction = 'NORTH'; public readonly initialPosition: Position; private quadtreeKey: QuadtreeKey = null; + private _exists: boolean = true; public constructor(npcSpawn: NpcSpawn, cacheData: NpcDefinition) { super(); @@ -38,7 +55,7 @@ export class Npc extends Mob { this._name = cacheData.name; this._combatLevel = cacheData.combatLevel; this._animations = cacheData.animations as NpcAnimations; - this.options = cacheData.actions; + this.options = cacheData.options; this.position = new Position(npcSpawn.x, npcSpawn.y, npcSpawn.level); this.initialPosition = new Position(npcSpawn.x, npcSpawn.y, npcSpawn.level); @@ -47,13 +64,20 @@ export class Npc extends Mob { } if(npcSpawn.face) { - this._initialFaceDirection = npcSpawn.face; + this.faceDirection = directionData[npcSpawn.face].index; } } public init(): void { world.chunkManager.getChunkForWorldPosition(this.position).addNpc(this); this.initiateRandomMovement(); + + new Promise(resolve => { + npcInitPlugins + .filter(plugin => basicNumberFilter(plugin.npcIds, this.id)) + .forEach(plugin => plugin.action({npc: this})); + resolve(); + }); } public async tick(): Promise { @@ -70,8 +94,37 @@ export class Npc extends Mob { }); } + /** + * Forces the Npc to speak the given message to the open world. + * @param message The message for the Npc to say. + */ + public say(message: string): void { + this.updateFlags.addChatMessage({ message }); + } + + /** + * Whether or not the Npc can currently move. + */ public canMove(): boolean { - return this.updateFlags.faceMob === undefined && this.updateFlags.animation === undefined; + return this.updateFlags.faceActor === undefined && this.updateFlags.animation === undefined; + } + + /** + * Plays a sound at the Npc's location for all nearby players. + * @param soundId The ID of the sound effect. + * @param volume The volume to play the sound at. + */ + public playSound(soundId: number, volume: number): void { + world.playLocationSound(this.position, soundId, volume); + } + + /** + * Transforms the Npc visually into a different Npc. + * @param id The id of the Npc to transform into. + */ + public setNewId(id: number): void { + this.id = id; + this.updateFlags.appearanceUpdateRequired = true; } public equals(other: Npc): boolean { @@ -89,7 +142,7 @@ export class Npc extends Mob { world.npcTree.remove(this.quadtreeKey); } - this.quadtreeKey = { x: position.x, y: position.y, mob: this }; + this.quadtreeKey = {x: position.x, y: position.y, actor: this}; world.npcTree.push(this.quadtreeKey); } @@ -113,7 +166,11 @@ export class Npc extends Mob { return this._movementRadius; } - public get initialFaceDirection(): Direction { - return this._initialFaceDirection; + public get exists(): boolean { + return this._exists; + } + + public set exists(value: boolean) { + this._exists = value; } } diff --git a/src/world/actor/player/action/action.ts b/src/world/actor/player/action/action.ts new file mode 100644 index 000000000..6e0ffdf20 --- /dev/null +++ b/src/world/actor/player/action/action.ts @@ -0,0 +1,106 @@ +import { Player } from '@server/world/actor/player/player'; +import { Position } from '@server/world/position'; +import { Subject, timer } from 'rxjs'; +import { World } from '@server/world/world'; +import { LocationObject } from '@runejs/cache-parser'; +import { Npc } from '@server/world/actor/npc/npc'; + +/** + * A type of action where something is being interacted with. + */ +export interface InteractingAction { + interactingObject?: LocationObject; +} + +/** + * A type of action that loops until either one of three things happens: + * 1. A player is specified within `options` who's `actionsCancelled` event has been fired during the loop. + * 2. An npc is specified within `options` who no longer exists at some point during the loop. + * 3. The `cancel()` function is manually called, presumably when the purpose of the loop has been completed. + * @param options Options to provide to the looping action, which include: + * `ticks` the number of game ticks between loop cycles. Defaults to 1 game tick between loops. + * `delayTicks` the number of game ticks to wait before starting the first loop. Defaults to 0 game ticks. + * `player` the player that the loop belongs to. Providing this field will cause the loop to cancel if this + * player's `actionsCancelled` is fired during the loop. + * `npc` the npc that the loop belongs to. This will Providing this field will cause the loop to cancel if + * this npc is flagged to no longer exist during the loop. + */ +export const loopingAction = (options: { ticks?: number, delayTicks?: number, npc?: Npc, player?: Player }) => { + const { ticks, delayTicks, npc, player } = options; + const event: Subject = new Subject(); + + const subscription = timer(delayTicks === undefined ? 0 : (delayTicks * World.TICK_LENGTH), + ticks === undefined ? World.TICK_LENGTH : (ticks * World.TICK_LENGTH)).subscribe(() => { + if(npc && !npc.exists) { + event.complete(); + subscription.unsubscribe(); + return; + } + + event.next(); + }); + + let actionCancelled; + + if(player) { + actionCancelled = player.actionsCancelled.subscribe(() => { + subscription.unsubscribe(); + actionCancelled.unsubscribe(); + event.complete(); + }); + } + + return { event, cancel: () => { + subscription.unsubscribe(); + + if(actionCancelled) { + actionCancelled.unsubscribe(); + } + + event.complete(); + } }; +}; + +/** + * A walk-to type of action that requires the specified player to walk to a specific destination before proceeding. + * Note that this does not force the player to walk, it simply checks to see if the player is walking where specified. + * @param player The player that must walk to a specific position. + * @param position The position that the player needs to end up at. + * @param interactingAction [optional] The information about the interaction that the player is making. Not required. + * @TODO change to 600ms / 1 check per game cycle? + */ +export const walkToAction = async (player: Player, position: Position, interactingAction?: InteractingAction): Promise => { + return new Promise((resolve, reject) => { + player.walkingTo = position; + + const inter = setInterval(() => { + if(!player.walkingTo || !player.walkingTo.equals(position)) { + reject(); + clearInterval(inter); + return; + } + + if(!player.walkingQueue.moving()) { + if(!interactingAction) { + if(player.position.distanceBetween(position) > 1) { + reject(); + } else { + resolve(); + } + } else { + if(interactingAction.interactingObject) { + const locationObject = interactingAction.interactingObject; + if(player.position.withinInteractionDistance(locationObject)) { + resolve(); + } else { + reject(); + } + } + } + + clearInterval(inter); + player.walkingTo = null; + } + }, 100); + }); +}; diff --git a/src/world/actor/player/action/button-action.ts b/src/world/actor/player/action/button-action.ts new file mode 100644 index 000000000..dade9e302 --- /dev/null +++ b/src/world/actor/player/action/button-action.ts @@ -0,0 +1,73 @@ +import { Player } from '@server/world/actor/player/player'; +import { pluginFilter } from '@server/plugins/plugin-loader'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; + +/** + * The definition for a button action function. + */ +export type buttonAction = (details: ButtonActionDetails) => void; + +/** + * Details about a button action. + */ +export interface ButtonActionDetails { + // The player performing the action. + player: Player; + // The ID of the UI widget that the button is on. + widgetId: number; + // The child ID of the button within the UI widget. + buttonId: number; +} + +/** + * Defines a button interaction plugin. + */ +export interface ButtonActionPlugin extends ActionPlugin { + // The ID of the UI widget that the button is on. + widgetId: number; + // The child ID or list of child IDs of the button(s) within the UI widget. + buttonIds?: number | number[]; + // The action function to be performed. + action: buttonAction; + // Whether or not this item action should cancel other running or queued actions. + cancelActions?: boolean; +} + +/** + * A directory of all button interaction plugins. + */ +let buttonInteractions: ButtonActionPlugin[] = [ +]; + +/** + * Sets the list of button interaction plugins. + * @param plugins The plugin list. + */ +export const setButtonPlugins = (plugins: ActionPlugin[]): void => { + buttonInteractions = plugins as ButtonActionPlugin[]; +}; + +export const buttonAction = (player: Player, widgetId: number, buttonId: number): void => { + // Find all item on item action plugins that match this action + let interactionActions = buttonInteractions.filter(plugin => questFilter(player, plugin) && + plugin.widgetId === widgetId && (plugin.buttonIds === undefined || pluginFilter(plugin.buttonIds, buttonId))); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled button interaction: ${widgetId}:${buttonId}`); + return; + } + + // Immediately run the plugins + for(const plugin of interactionActions) { + if(plugin.cancelActions) { + player.actionsCancelled.next(); + } + + plugin.action({ player, widgetId, buttonId }); + } +}; diff --git a/src/world/actor/player/action/combat-action.ts b/src/world/actor/player/action/combat-action.ts new file mode 100644 index 000000000..9d69e1b78 --- /dev/null +++ b/src/world/actor/player/action/combat-action.ts @@ -0,0 +1,15 @@ +import { Actor } from '@server/world/actor/actor'; + +export class CombatAction { + + public constructor(private _actor: Actor, private _opponent: Actor) { + } + + get actor(): Actor { + return this._actor; + } + + get opponent(): Actor { + return this._opponent; + } +} diff --git a/src/world/mob/player/action/dialogue-action.ts b/src/world/actor/player/action/dialogue-action.ts similarity index 54% rename from src/world/mob/player/action/dialogue-action.ts rename to src/world/actor/player/action/dialogue-action.ts index 77a19eb84..9da461fd7 100644 --- a/src/world/mob/player/action/dialogue-action.ts +++ b/src/world/actor/player/action/dialogue-action.ts @@ -1,19 +1,23 @@ -import { Player } from '@server/world/mob/player/player'; -import { gameCache } from '@server/game-server'; -import { Npc } from '@server/world/mob/npc/npc'; -import { skillDetails } from '@server/world/mob/skills'; - -const widgetIds = { - PLAYER: [ 968, 973, 979, 986 ], - NPC: [ 4882, 4887, 4893, 4900 ], - OPTIONS: [ 2459, 2469, 2480, 2492 ] +import { Player } from '@server/world/actor/player/player'; +import { cache } from '@server/game-server'; +import { Npc } from '@server/world/actor/npc/npc'; +import { WidgetsClosedWarning } from '@server/error-handling'; + +export const dialogueWidgetIds = { + PLAYER: [ 64, 65, 66, 67 ], + NPC: [ 241, 242, 243, 244 ], + OPTIONS: [ 228, 230, 232, 234 ], + TEXT: [ 210, 211, 212, 213, 214 ] }; +/** + * Min -> max lines for a specific dialogue type. + */ const lineConstraints = { PLAYER: [ 1, 4 ], NPC: [ 1, 4 ], OPTIONS: [ 2, 5 ], - LEVEL_UP: [ 2, 2 ] + TEXT: [ 1, 5 ] }; export enum DialogueEmote { @@ -49,7 +53,7 @@ export enum DialogueEmote { ANGRY_4 = 617 } -export type DialogueType = 'PLAYER' | 'NPC' | 'OPTIONS' | 'LEVEL_UP'; +export type DialogueType = 'PLAYER' | 'NPC' | 'OPTIONS' | 'TEXT'; export interface DialogueOptions { type: DialogueType; @@ -60,6 +64,7 @@ export interface DialogueOptions { lines: string[]; } +// @DEPRECATED export class DialogueAction { private _action: number = null; @@ -67,29 +72,25 @@ export class DialogueAction { public constructor(private readonly p: Player) { } - public player(emote: DialogueEmote, lines: string[]): Promise { + public async player(emote: DialogueEmote, lines: string[]): Promise { return this.dialogue({ emote, lines, type: 'PLAYER' }); } - public npc(npc: Npc, emote: DialogueEmote, lines: string[]): Promise { - return this.dialogue({ emote, lines, type: 'NPC', npc: npc.id }); + public async npc(npc: Npc | number, emote: DialogueEmote, lines: string[]): Promise { + return this.dialogue({ emote, lines, type: 'NPC', npc: typeof npc === 'number' ? npc : npc.id }); } - public options(title: string, options: string[]): Promise { + public async options(title: string, options: string[]): Promise { return this.dialogue({ type: 'OPTIONS', title, lines: options }); } - public dialogue(options: DialogueOptions): Promise { + public async dialogue(options: DialogueOptions): Promise { if(options.lines.length < lineConstraints[options.type][0] || options.lines.length > lineConstraints[options.type][1]) { - throw 'Invalid line length.'; + throw new Error('Invalid line length.'); } if(options.type === 'NPC' && options.npc === undefined) { - throw 'NPC not supplied.'; - } - - if(options.type === 'LEVEL_UP' && options.skillId === undefined) { - throw 'Skill ID not supplied.'; + throw new Error('NPC not supplied.'); } this._action = null; @@ -99,19 +100,13 @@ export class DialogueAction { widgetIndex--; } - let widgetId = -1; - - if(options.type === 'LEVEL_UP') { - widgetId = skillDetails.map(skill => skill.advancementWidgetId === undefined ? -1 : skill.advancementWidgetId)[options.skillId]; - } else { - widgetId = widgetIds[options.type][widgetIndex]; - } + const widgetId = dialogueWidgetIds[options.type][widgetIndex]; if(widgetId === undefined || widgetId === null || widgetId === -1) { return Promise.resolve(this); } - let textOffset = 1; + let textOffset = 0; if(options.type === 'PLAYER' || options.type === 'NPC') { if(!options.emote) { @@ -119,22 +114,24 @@ export class DialogueAction { } if(options.type === 'NPC') { - this.p.packetSender.setWidgetModel2(widgetId + 1, options.npc); - this.p.packetSender.updateWidgetString(widgetId + 2, gameCache.npcDefinitions.get(options.npc).name); + this.p.outgoingPackets.setWidgetNpcHead(widgetId, 0, options.npc); + this.p.outgoingPackets.updateWidgetString(widgetId, 1, cache.npcDefinitions.get(options.npc).name); } else if(options.type === 'PLAYER') { - this.p.packetSender.setWidgetPlayerHead(widgetId + 1); - this.p.packetSender.updateWidgetString(widgetId + 2, this.p.username); + this.p.outgoingPackets.setWidgetPlayerHead(widgetId, 0); + this.p.outgoingPackets.updateWidgetString(widgetId, 1, this.p.username); } - this.p.packetSender.playWidgetAnimation(widgetId + 1, options.emote); - textOffset += 2; + this.p.outgoingPackets.playWidgetAnimation(widgetId, 0, options.emote); + textOffset = 2; } else if(options.type === 'OPTIONS') { - this.p.packetSender.updateWidgetString(widgetId + 1, options.title); - textOffset += 1; + this.p.outgoingPackets.updateWidgetString(widgetId, 0, options.title); + textOffset = 1; + } else if(options.type === 'TEXT') { + textOffset = 0; } for(let i = 0; i < options.lines.length; i++) { - this.p.packetSender.updateWidgetString(widgetId + textOffset + i, options.lines[i]); + this.p.outgoingPackets.updateWidgetString(widgetId, textOffset + i, options.lines[i]); } return new Promise((resolve, reject) => { @@ -142,10 +139,11 @@ export class DialogueAction { widgetId: widgetId, type: 'CHAT', closeOnWalk: true, - forceClosed: () => reject('WIDGET_CLOSED') + forceClosed: () => reject(new WidgetsClosedWarning()) }; - this.p.dialogueInteractionEvent.subscribe(action => { + const sub = this.p.dialogueInteractionEvent.subscribe(action => { + sub.unsubscribe(); this._action = action; resolve(this); }); @@ -153,7 +151,7 @@ export class DialogueAction { } public close(): void { - this.p.packetSender.closeActiveWidgets(); + this.p.outgoingPackets.closeActiveWidgets(); } public get action(): number { @@ -165,7 +163,7 @@ export class DialogueAction { } } -export const dialogueAction = (player: Player, options?: DialogueOptions): Promise => { +export const dialogueAction = async (player: Player, options?: DialogueOptions): Promise => { if(options) { return new DialogueAction(player).dialogue(options); } else { diff --git a/src/world/actor/player/action/input-command-action.ts b/src/world/actor/player/action/input-command-action.ts new file mode 100644 index 000000000..af5b2081d --- /dev/null +++ b/src/world/actor/player/action/input-command-action.ts @@ -0,0 +1,121 @@ +import { Player } from '../player'; +import { ActionPlugin } from '@server/plugins/plugin'; + +/** + * The definition for a command action function. + */ +export type commandAction = (details: CommandActionDetails) => void; + +/** + * Details about a command action. + */ +export interface CommandActionDetails { + // The player performing the action. + player: Player; + // The command that the player entered. + command: string; + // If the player used the console + isConsole: boolean; + // The arguments that the player entered for their command. + args: { [key: string]: number | string }; +} + +/** + * Defines a command interaction plugin. + */ +export interface CommandActionPlugin extends ActionPlugin { + // The single command or list of commands that this action applies to. + commands: string | string[]; + // The potential arguments for this command action. + args?: { + name: string; + type: 'number' | 'string'; + defaultValue?: number | string; + }[]; + // The action function to be performed. + action: commandAction; +} + +/** + * A directory of all command interaction plugins. + */ +let commandInteractions: CommandActionPlugin[] = []; + +/** + * Sets the list of command interaction plugins. + * @param plugins The plugin list. + */ +export const setCommandPlugins = (plugins: ActionPlugin[]): void => { + commandInteractions = plugins as CommandActionPlugin[]; +}; + +export const inputCommandAction = (player: Player, command: string, isConsole: boolean, inputArgs: string[]): void => { + const plugins = commandInteractions.filter(plugin => { + if (Array.isArray(plugin.commands)) { + return plugin.commands.indexOf(command) !== -1; + } else { + return plugin.commands === command; + } + }); + + if (plugins.length === 0) { + player.sendLogMessage(`Unhandled command: ${command}`, isConsole); + return; + } + + plugins.forEach(plugin => { + try { + if (plugin.args) { + const pluginArgs = plugin.args; + let syntaxError = `Syntax error. Try ::${command}`; + + pluginArgs.forEach(pluginArg => { + syntaxError += ` ${pluginArg.name}:${pluginArg.type}${pluginArg.defaultValue === undefined ? '' : '?'}`; + }); + + const requiredArgLength = plugin.args.filter(arg => arg.defaultValue !== undefined).length; + if (requiredArgLength > inputArgs.length) { + player.sendLogMessage(syntaxError, isConsole); + return; + } + + const actionArgs = {}; + + for (let i = 0; i < plugin.args.length; i++) { + let argValue: string | number = inputArgs[i] || null; + const pluginArg = plugin.args[i]; + + if (argValue === null) { + if (pluginArg.defaultValue === undefined) { + player.sendLogMessage(syntaxError, isConsole); + return; + } else { + argValue = pluginArg.defaultValue; + } + } else { + if (pluginArg.type === 'number') { + argValue = parseInt(argValue); + if (isNaN(argValue)) { + player.sendLogMessage(syntaxError, isConsole); + return; + } + } else { + if (!argValue || argValue.trim() === '') { + player.sendLogMessage(syntaxError, isConsole); + return; + } + } + } + + actionArgs[pluginArg.name] = argValue; + } + + plugin.action({player, command, isConsole, args: actionArgs}); + } else { + plugin.action({player, command, isConsole, args: {}}); + } + } catch (commandError) { + player.sendLogMessage(`Command error: ${commandError}`, isConsole); + } + }); +}; diff --git a/src/world/actor/player/action/item-action.ts b/src/world/actor/player/action/item-action.ts new file mode 100644 index 000000000..b1738b5ad --- /dev/null +++ b/src/world/actor/player/action/item-action.ts @@ -0,0 +1,155 @@ +import { Player } from '@server/world/actor/player/player'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; +import { ItemContainer } from '@server/world/items/item-container'; +import { Item } from '@server/world/items/item'; +import { basicNumberFilter, basicStringFilter } from '@server/plugins/plugin-loader'; +import { world } from '@server/game-server'; +import { ItemDetails } from '@server/world/config/item-data'; + +/** + * The definition for an item action function. + */ +export type itemAction = (details: ItemActionDetails) => void; + +/** + * Details about an item being interacted with. + */ +export interface ItemActionDetails { + // The player performing the action. + player: Player; + // The ID of the item being interacted with. + itemId: number; + // The container slot that the item being interacted with is in. + itemSlot: number; + // The ID of the UI widget that the item is in. + widgetId: number; + // The ID of the UI container that the item is in. + containerId: number; + // Additional details about the item. + itemDetails: ItemDetails; + // The option that the player used (ie "equip" or "drop"). + option: string; +} + +/** + * Defines an item interaction plugin. + */ +export interface ItemActionPlugin extends ActionPlugin { + // A single game item ID or a list of item IDs that this action applies to. + itemIds?: number | number[]; + // A single UI widget ID or a list of widget IDs that this action applies to. + widgets?: { widgetId: number, containerId: number } | { widgetId: number, containerId: number }[]; + // A single option name or a list of option names that this action applies to. + options?: string | string[]; + // The action function to be performed. + action: itemAction; + // Whether or not this item action should cancel other running or queued actions. + cancelOtherActions?: boolean; +} + +/** + * A directory of all object interaction plugins. + */ +let itemInteractions: ItemActionPlugin[] = []; + +/** + * Sets the list of object interaction plugins. + * @param plugins The plugin list. + */ +export const setItemPlugins = (plugins: ActionPlugin[]): void => { + itemInteractions = plugins as ItemActionPlugin[]; +}; + +export const getItemFromContainer = (itemId: number, slot: number, container: ItemContainer): Item => { + if(slot < 0 || slot > container.items.length - 1) { + return null; + } + + const item = container.items[slot]; + if(!item || item.itemId !== itemId) { + return null; + } + + return item; +}; + +// @TODO priority and cancelling other (lower priority) actions +export const itemAction = (player: Player, itemId: number, slot: number, widgetId: number, containerId: number, option: string): void => { + if(player.busy) { + return; + } + + let cancelActions = false; + + // Find all object action plugins that reference this location object + let interactionActions = itemInteractions.filter(plugin => { + if(!questFilter(player, plugin)) { + return false; + } + + if(plugin.itemIds !== undefined) { + if(!basicNumberFilter(plugin.itemIds, itemId)) { + return false; + } + } + + if(plugin.widgets !== undefined) { + if(Array.isArray(plugin.widgets)) { + let found = false; + for(const widget of plugin.widgets) { + if(widget.widgetId === widgetId && widget.containerId === containerId) { + found = true; + break; + } + } + + if(!found) { + return false; + } + } else { + if(plugin.widgets.widgetId !== widgetId || plugin.widgets.containerId !== containerId) { + return false; + } + } + } + + if(plugin.options !== undefined) { + if(!basicStringFilter(plugin.options, option)) { + return false; + } + } + + if(plugin.cancelOtherActions) { + cancelActions = true; + } + return true; + }); + + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled item option: ${option} ${itemId} in slot ${slot} within widget ${widgetId}:${containerId}`); + return; + } + + if(cancelActions) { + player.actionsCancelled.next(); + } + + for(const plugin of interactionActions) { + plugin.action({ + player, + itemId, + itemSlot: slot, + widgetId, + containerId, + itemDetails: world.itemData.get(itemId), + option + }); + } + +}; diff --git a/src/world/actor/player/action/item-on-item-action.ts b/src/world/actor/player/action/item-on-item-action.ts new file mode 100644 index 000000000..8099f767d --- /dev/null +++ b/src/world/actor/player/action/item-on-item-action.ts @@ -0,0 +1,84 @@ +import { Player } from '@server/world/actor/player/player'; +import { Item } from '@server/world/items/item'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; + +/** + * The definition for an item on item action function. + */ +export type itemOnItemAction = (details: ItemOnItemActionDetails) => void; + +/** + * Details about an item on item action. + */ +export interface ItemOnItemActionDetails { + // The player performing the action. + player: Player; + // The item being used. + usedItem: Item; + // The item that the first item is being used on. + usedWithItem: Item; + // The container slot that the item being used is in. + usedSlot: number; + // The container slot that the second item is in. + usedWithSlot: number; + // The ID of the UI widget that the item being used is in. + usedWidgetId: number; + // The ID of the UI widget that the second item is in. + usedWithWidgetId: number; +} + +/** + * Defines an item on item interaction plugin. + */ +export interface ItemOnItemActionPlugin extends ActionPlugin { + // The item pairs being used. Each item can be used on the other, so item order does not matter. + items: { item1: number, item2: number }[]; + // The action function to be performed. + action: itemOnItemAction; +} + +/** + * A directory of all item on item interaction plugins. + */ +let itemOnItemInteractions: ItemOnItemActionPlugin[] = [ +]; + +/** + * Sets the list of item on item interaction plugins. + * @param plugins The plugin list. + */ +export const setItemOnItemPlugins = (plugins: ActionPlugin[]): void => { + itemOnItemInteractions = plugins as ItemOnItemActionPlugin[]; +}; + +export const itemOnItemAction = (player: Player, + usedItem: Item, usedSlot: number, usedWidgetId: number, + usedWithItem: Item, usedWithSlot: number, usedWithWidgetId: number): void => { + if(player.busy) { + return; + } + + // Find all item on item action plugins that match this action + let interactionActions = itemOnItemInteractions.filter(plugin => + questFilter(player, plugin) && + (plugin.items.findIndex(i => i.item1 === usedItem.itemId && i.item2 === usedWithItem.itemId) !== -1 || + plugin.items.findIndex(i => i.item2 === usedItem.itemId && i.item1 === usedWithItem.itemId) !== -1)); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled item on item interaction: ${usedItem.itemId} on ${usedWithItem.itemId}`); + return; + } + + player.actionsCancelled.next(); + + // Immediately run the plugins + for(const plugin of interactionActions) { + plugin.action({ player, usedItem, usedWithItem, usedSlot, usedWithSlot, + usedWidgetId: usedWidgetId, usedWithWidgetId: usedWithWidgetId }); + } +}; diff --git a/src/world/actor/player/action/item-on-npc-action.ts b/src/world/actor/player/action/item-on-npc-action.ts new file mode 100644 index 000000000..b470ca5a1 --- /dev/null +++ b/src/world/actor/player/action/item-on-npc-action.ts @@ -0,0 +1,121 @@ +import { Player } from '@server/world/actor/player/player'; +import { Position } from '@server/world/position'; +import { walkToAction } from '@server/world/actor/player/action/action'; +import { pluginFilter } from '@server/plugins/plugin-loader'; +import { logger } from '@runejs/logger/dist/logger'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; +import { Item } from '@server/world/items/item'; +import { Npc } from '@server/world/actor/npc/npc'; + +/** + * The definition for an item on npc action function. + */ +export type itemOnNpcAction = (details: ItemOnNpcActionDetails) => void; + +/** + * Details about an npc being interacted with. and the item being used. + */ +export interface ItemOnNpcActionDetails { + // The player performing the action. + player: Player; + // The NPC the action is being performed on. + npc: Npc; + // The position that the NPC was at when the action was initiated. + position: Position; + // The item being used. + item: Item; + // The ID of the UI widget that the item being used is in. + itemWidgetId: number; + // The ID of the UI container that the item being used is in. + itemContainerId: number; +} + +/** + * Defines an item on npc interaction plugin. + * A list of npc ids that apply to the plugin, the items that can be performed on, + * and whether or not the player must first walk to the npc. + */ +export interface ItemOnNpcActionPlugin extends ActionPlugin { + // A single NPC ID or a list of NPC IDs that this action applies to. + npcsIds: number | number[]; + // A single game item ID or a list of item IDs that this action applies to. + itemIds: number | number[]; + // Whether or not the player needs to walk to this NPC before performing the action. + walkTo: boolean; + // The action function to be performed. + action: itemOnNpcAction; +} + +/** + * A directory of all item on npc interaction plugins. + */ +let itemOnNpcInteractions: ItemOnNpcActionPlugin[] = []; + +/** + * Sets the list of item on npc interaction plugins. + * @param plugins The plugin list. + */ +export const setItemOnNpcPlugins = (plugins: ActionPlugin[]): void => { + itemOnNpcInteractions = plugins as ItemOnNpcActionPlugin[]; +}; + +// @TODO priority and cancelling other (lower priority) actions +export const itemOnNpcAction = (player: Player, npc: Npc, + position: Position, item: Item, itemWidgetId: number, itemContainerId: number): void => { + if(player.busy) { + return; + } + + // Find all item on npc action plugins that reference this npc and item + let interactionActions = itemOnNpcInteractions.filter(plugin => + questFilter(player, plugin) && + pluginFilter(plugin.npcsIds, npc.id) && pluginFilter(plugin.itemIds, item.itemId)); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled item on npc interaction: ${ item.itemId } on ${ npc.name } ` + + `(id-${ npc.id }) @ ${ position.x },${ position.y },${ position.level }`); + return; + } + + player.actionsCancelled.next(); + + // Separate out walk-to actions from immediate actions + const walkToPlugins = interactionActions.filter(plugin => plugin.walkTo); + const immediatePlugins = interactionActions.filter(plugin => !plugin.walkTo); + + // Make sure we walk to the npc before running any of the walk-to plugins + if(walkToPlugins.length !== 0) { + walkToAction(player, position) + .then(() => { + player.face(position); + + walkToPlugins.forEach(plugin => + plugin.action({ + player, + npc, + position, + item, + itemWidgetId, + itemContainerId + })); + }) + .catch(() => logger.warn(`Unable to complete walk-to action.`)); + } + + // Immediately run any non-walk-to plugins + for(const plugin of immediatePlugins) { + plugin.action({ + player, + npc, + position, + item, + itemWidgetId, + itemContainerId + }); + } +}; diff --git a/src/world/actor/player/action/item-on-object-action.ts b/src/world/actor/player/action/item-on-object-action.ts new file mode 100644 index 000000000..d5d05f24a --- /dev/null +++ b/src/world/actor/player/action/item-on-object-action.ts @@ -0,0 +1,133 @@ +import { Player } from '@server/world/actor/player/player'; +import { LocationObject, LocationObjectDefinition } from '@runejs/cache-parser'; +import { Position } from '@server/world/position'; +import { walkToAction } from '@server/world/actor/player/action/action'; +import { pluginFilter } from '@server/plugins/plugin-loader'; +import { logger } from '@runejs/logger/dist/logger'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; +import { Item } from '@server/world/items/item'; + +/** + * The definition for an item on object action function. + */ +export type itemOnObjectAction = (details: ItemOnObjectActionDetails) => void; + +/** + * Details about an object being interacted with. and the item being used. + */ +export interface ItemOnObjectActionDetails { + // The player performing the action. + player: Player; + // The object the action is being performed on. + object: LocationObject; + // Additional details about the object that the action is being performed on. + objectDefinition: LocationObjectDefinition; + // The position that the game object was at when the action was initiated. + position: Position; + // The item being used. + item: Item; + // The ID of the UI widget that the item being used is in. + itemWidgetId: number; + // The ID of the UI container that the item being used is in. + itemContainerId: number; + // Whether or not this game object is an original map object or if it has been added/replaced. + cacheOriginal: boolean; +} + +/** + * Defines an item on object interaction plugin. + * A list of object ids that apply to the plugin, the options for the object, the items that can be performed on, + * and whether or not the player must first walk to the object. + */ +export interface ItemOnObjectActionPlugin extends ActionPlugin { + // A single game object ID or a list of object IDs that this action applies to. + objectIds: number | number[]; + // A single game item ID or a list of item IDs that this action applies to. + itemIds: number | number[]; + // Whether or not the player needs to walk to this object before performing the action. + walkTo: boolean; + // The action function to be performed. + action: itemOnObjectAction; +} + +/** + * A directory of all item on object interaction plugins. + */ +let itemOnObjectInteractions: ItemOnObjectActionPlugin[] = []; + +/** + * Sets the list of item on object interaction plugins. + * @param plugins The plugin list. + */ +export const setItemOnObjectPlugins = (plugins: ActionPlugin[]): void => { + itemOnObjectInteractions = plugins as ItemOnObjectActionPlugin[]; +}; + +// @TODO priority and cancelling other (lower priority) actions +export const itemOnObjectAction = (player: Player, locationObject: LocationObject, locationObjectDefinition: LocationObjectDefinition, + position: Position, item: Item, itemWidgetId: number, itemContainerId: number, cacheOriginal: boolean): void => { + if(player.busy) { + return; + } + + // Find all item on object action plugins that reference this location object + let interactionActions = itemOnObjectInteractions.filter(plugin => questFilter(player, plugin) && pluginFilter(plugin.objectIds, locationObject.objectId)); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + // Find all item on object action plugins that reference this item + if(interactionActions.length !== 0) { + interactionActions = interactionActions.filter(plugin => pluginFilter(plugin.itemIds, item.itemId)); + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled item on object interaction: ${ item.itemId } on ${ locationObjectDefinition.name } ` + + `(id-${ locationObject.objectId }) @ ${ position.x },${ position.y },${ position.level }`); + return; + } + + player.actionsCancelled.next(); + + // Separate out walk-to actions from immediate actions + const walkToPlugins = interactionActions.filter(plugin => plugin.walkTo); + const immediatePlugins = interactionActions.filter(plugin => !plugin.walkTo); + + // Make sure we walk to the object before running any of the walk-to plugins + if(walkToPlugins.length !== 0) { + walkToAction(player, position, { interactingObject: locationObject }) + .then(() => { + player.face(position); + + walkToPlugins.forEach(plugin => + plugin.action({ + player, + object: locationObject, + objectDefinition: locationObjectDefinition, + position, + item, + itemWidgetId, + itemContainerId, + cacheOriginal + })); + }) + .catch(() => logger.warn(`Unable to complete walk-to action.`)); + } + + // Immediately run any non-walk-to plugins + if(immediatePlugins.length !== 0) { + immediatePlugins.forEach(plugin => + plugin.action({ + player, + object: locationObject, + objectDefinition: locationObjectDefinition, + position, + item, + itemWidgetId, + itemContainerId, + cacheOriginal + })); + } +}; diff --git a/src/world/actor/player/action/item-selection-action.ts b/src/world/actor/player/action/item-selection-action.ts new file mode 100644 index 000000000..91c04254c --- /dev/null +++ b/src/world/actor/player/action/item-selection-action.ts @@ -0,0 +1,159 @@ +import { Player } from '@server/world/actor/player/player'; + +const amounts = [ + 1, 5, 10, 0 +]; + +const widgets = { + // 303-306 - what would you like to make? + 303: { + items: [ 2, 3 ], + text: [ 7, 11 ], + options: [ [ 7, 6, 5, 4 ], [ 11, 10, 9, 8 ] ] + }, + 304: { + items: [ 2, 3, 4 ], + text: [ 8, 12, 16 ], + options: [ [ 8, 7, 6, 5 ], [ 12, 11, 10, 9 ], [ 16, 15, 14, 13 ] ] + }, + 305: { + items: [ 2, 3, 4, 5 ], + text: [ 9, 13, 17, 21 ], + options: [ [ 9, 8, 7, 6 ], [ 13, 12, 11, 10 ], [ 17, 16, 15, 14 ], [ 21, 20, 19, 18 ] ] + }, + 306: { + items: [ 2, 3, 4, 5, 6 ], + text: [ 10, 14, 18, 22, 26 ], + options: [ [ 10, 9, 8, 7 ], [ 14, 13, 12, 11 ], [ 18, 17, 16, 15 ], [ 22, 21, 20, 19 ], [ 26, 25, 24, 23 ] ] + }, + 307: { // 307 - how many would you like to cook? + items: [ 2 ], + text: [ 6 ], + options: [ [ 6, 5, 4, 3 ] ] + }, + 309: { // 309 - how many would you like to make? + items: [ 2 ], + text: [ 6 ], + options: [ [ 6, 5, 4, 3 ] ] + } +}; + +export interface SelectableItem { + itemId: number; + itemName: string; + offset?: number; + zoom?: number; +} + +export interface ItemSelection { + itemId: number; + amount: number; +} + +export async function itemSelectionAction(player: Player, type: 'COOKING' | 'MAKING', items: SelectableItem[]): Promise { + let widgetId = 307; + + if(type === 'MAKING') { + if(items.length === 1) { + widgetId = 309; + } else { + if(items.length > 5) { + throw new Error(`Too many items provided to the item selection action!`); + } + + widgetId = (301 + items.length); + } + } + + const childIds = widgets[widgetId].items; + childIds.forEach((childId, index) => { + const itemInfo = items[index]; + + if(itemInfo.offset === undefined) { + itemInfo.offset = -12; + } + + if(itemInfo.zoom === undefined) { + itemInfo.zoom = 180; + } + + player.outgoingPackets.setItemOnWidget(widgetId, childId, itemInfo.itemId, itemInfo.zoom); + player.outgoingPackets.moveWidgetChild(widgetId, childId, 0, itemInfo.offset); + player.outgoingPackets.updateWidgetString(widgetId, widgets[widgetId].text[index], '\\n\\n\\n\\n' + itemInfo.itemName); + }); + + return new Promise((resolve, reject) => { + player.activeWidget = { + widgetId, + type: 'CHAT', + closeOnWalk: true + }; + + let actionsSub = player.actionsCancelled.subscribe(() => { + actionsSub.unsubscribe(); + reject('Pending Actions Cancelled'); + }); + + const interactionSub = player.dialogueInteractionEvent.subscribe(childId => { + if(!player.activeWidget || player.activeWidget.widgetId !== widgetId) { + interactionSub.unsubscribe(); + actionsSub.unsubscribe(); + reject('Active Widget Mismatch'); + return; + } + + const options = widgets[widgetId].options; + + const choiceIndex = options.findIndex(arr => arr.indexOf(childId) !== -1); + + if(choiceIndex === -1) { + interactionSub.unsubscribe(); + actionsSub.unsubscribe(); + reject('Choice Index Not Found'); + return; + } + + const optionIndex = options[choiceIndex].indexOf(childId); + + if(optionIndex === -1) { + interactionSub.unsubscribe(); + actionsSub.unsubscribe(); + reject('Option Index Not Found'); + return; + } + + const itemId = items[choiceIndex].itemId; + const amount = amounts[optionIndex]; + + if(amount === 0) { + actionsSub.unsubscribe(); + + player.outgoingPackets.showNumberInputDialogue(); + + actionsSub = player.actionsCancelled.subscribe(() => { + actionsSub.unsubscribe(); + reject('Pending Actions Cancelled'); + }); + + const inputSub = player.numericInputEvent.subscribe(input => { + inputSub.unsubscribe(); + actionsSub.unsubscribe(); + interactionSub.unsubscribe(); + + if(input < 1 || input > 2147483647) { + player.closeActiveWidgets(); + reject('Invalid User Amount Input'); + } else { + player.closeActiveWidgets(); + resolve({itemId, amount: input} as ItemSelection); + } + }); + } else { + actionsSub.unsubscribe(); + interactionSub.unsubscribe(); + player.closeActiveWidgets(); + resolve({itemId, amount} as ItemSelection); + } + }); + }); +} diff --git a/src/world/mob/player/action/npc-action.ts b/src/world/actor/player/action/npc-action.ts similarity index 60% rename from src/world/mob/player/action/npc-action.ts rename to src/world/actor/player/action/npc-action.ts index e39cec03b..a150a6a5f 100644 --- a/src/world/mob/player/action/npc-action.ts +++ b/src/world/actor/player/action/npc-action.ts @@ -1,10 +1,10 @@ -import { Player } from '@server/world/mob/player/player'; -import { Npc } from '@server/world/mob/npc/npc'; +import { Player } from '@server/world/actor/player/player'; +import { Npc } from '@server/world/actor/npc/npc'; import { Position } from '@server/world/position'; -import { walkToAction } from '@server/world/mob/player/action/action'; +import { walkToAction } from '@server/world/actor/player/action/action'; import { pluginFilter } from '@server/plugins/plugin-loader'; import { logger } from '@runejs/logger/dist/logger'; -import { ActionPlugin } from '@server/plugins/plugin'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; /** * The definition for an NPC action function. @@ -15,8 +15,11 @@ export type npcAction = (details: NpcActionDetails) => void; * Details about an NPC being interacted with. */ export interface NpcActionDetails { + // The player performing the action. player: Player; + // The NPC the action is being performed on. npc: Npc; + // The position that the NPC was at when the action was initiated. position: Position; } @@ -26,9 +29,13 @@ export interface NpcActionDetails { * and whether or not the player must first walk to the NPC. */ export interface NpcActionPlugin extends ActionPlugin { + // A single NPC ID or a list of NPC IDs that this action applies to. npcIds: number | number[]; + // A single option name or a list of option names that this action applies to. options: string | string[]; + // Whether or not the player needs to walk to this NPC before performing the action. walkTo: boolean; + // The action function to be performed. action: npcAction; } @@ -48,19 +55,28 @@ export const setNpcPlugins = (plugins: ActionPlugin[]): void => { // @TODO priority and cancelling other (lower priority) actions export const npcAction = (player: Player, npc: Npc, position: Position, option: string): void => { + if(player.busy) { + return; + } + // Find all NPC action plugins that reference this NPC - const interactionPlugins = npcInteractions.filter(plugin => pluginFilter(plugin.npcIds, npc.id, plugin.options, option)); + let interactionActions = npcInteractions.filter(plugin => questFilter(player, plugin) && pluginFilter(plugin.npcIds, npc.id, plugin.options, option)); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } - if(interactionPlugins.length === 0) { - player.packetSender.chatboxMessage(`Unhandled NPC interaction: ${option} ${npc.name} (id-${npc.id}) @ ${position.x},${position.y},${position.level}`); + if(interactionActions.length === 0) { + player.sendMessage(`Unhandled NPC interaction: ${option} ${npc.name} (id-${npc.id}) @ ${position.x},${position.y},${position.level}`); return; } player.actionsCancelled.next(); // Separate out walk-to actions from immediate actions - const walkToPlugins = interactionPlugins.filter(plugin => plugin.walkTo); - const immediatePlugins = interactionPlugins.filter(plugin => !plugin.walkTo); + const walkToPlugins = interactionActions.filter(plugin => plugin.walkTo); + const immediatePlugins = interactionActions.filter(plugin => !plugin.walkTo); // Make sure we walk to the NPC before running any of the walk-to plugins if(walkToPlugins.length !== 0) { diff --git a/src/world/actor/player/action/object-action.ts b/src/world/actor/player/action/object-action.ts new file mode 100644 index 000000000..f30ddba67 --- /dev/null +++ b/src/world/actor/player/action/object-action.ts @@ -0,0 +1,119 @@ +import { Player } from '@server/world/actor/player/player'; +import { LocationObject, LocationObjectDefinition } from '@runejs/cache-parser'; +import { Position } from '@server/world/position'; +import { walkToAction } from '@server/world/actor/player/action/action'; +import { pluginFilter } from '@server/plugins/plugin-loader'; +import { logger } from '@runejs/logger/dist/logger'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; + +/** + * The definition for an object action function. + */ +export type objectAction = (details: ObjectActionDetails) => void; + +/** + * Details about an object being interacted with. + */ +export interface ObjectActionDetails { + // The player performing the action. + player: Player; + // The object the action is being performed on. + object: LocationObject; + // Additional details about the object that the action is being performed on. + objectDefinition: LocationObjectDefinition; + // The position that the game object was at when the action was initiated. + position: Position; + // Whether or not this game object is an original map object or if it has been added/replaced. + cacheOriginal: boolean; + // The option that the player used (ie "cut" tree, or "smelt" furnace). + option: string; +} + +/** + * Defines an object interaction plugin. + * A list of object ids that apply to the plugin, the options for the object, the action to be performed, + * and whether or not the player must first walk to the object. + */ +export interface ObjectActionPlugin extends ActionPlugin { + // A single game object ID or a list of object IDs that this action applies to. + objectIds: number | number[]; + // A single option name or a list of option names that this action applies to. + options: string | string[]; + // Whether or not the player needs to walk to this object before performing the action. + walkTo: boolean; + // The action function to be performed. + action: objectAction; +} + +/** + * A directory of all object interaction plugins. + */ +let objectInteractions: ObjectActionPlugin[] = []; + +/** + * Sets the list of object interaction plugins. + * @param plugins The plugin list. + */ +export const setObjectPlugins = (plugins: ActionPlugin[]): void => { + objectInteractions = plugins as ObjectActionPlugin[]; +}; + +// @TODO priority and cancelling other (lower priority) actions +export const objectAction = (player: Player, locationObject: LocationObject, locationObjectDefinition: LocationObjectDefinition, + position: Position, option: string, cacheOriginal: boolean): void => { + if(player.busy) { + return; + } + + // Find all object action plugins that reference this location object + let interactionActions = objectInteractions.filter(plugin => questFilter(player, plugin) && pluginFilter(plugin.objectIds, locationObject.objectId, plugin.options, option)); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled object interaction: ${option} ${locationObjectDefinition.name} ` + + `(id-${locationObject.objectId}) @ ${position.x},${position.y},${position.level}`); + return; + } + + player.actionsCancelled.next(); + + // Separate out walk-to actions from immediate actions + const walkToPlugins = interactionActions.filter(plugin => plugin.walkTo); + const immediatePlugins = interactionActions.filter(plugin => !plugin.walkTo); + + // Make sure we walk to the object before running any of the walk-to plugins + if(walkToPlugins.length !== 0) { + walkToAction(player, position, { interactingObject: locationObject }) + .then(() => { + player.face(position); + + walkToPlugins.forEach(plugin => + plugin.action({ + player, + object: locationObject, + objectDefinition: locationObjectDefinition, + option, + position, + cacheOriginal + })); + }) + .catch(() => logger.warn(`Unable to complete walk-to action.`)); + } + + // Immediately run any non-walk-to plugins + if(immediatePlugins.length !== 0) { + immediatePlugins.forEach(plugin => + plugin.action({ + player, + object: locationObject, + objectDefinition: locationObjectDefinition, + option, + position, + cacheOriginal + })); + } +}; diff --git a/src/world/actor/player/action/shop-action.ts b/src/world/actor/player/action/shop-action.ts new file mode 100644 index 000000000..492e56197 --- /dev/null +++ b/src/world/actor/player/action/shop-action.ts @@ -0,0 +1,35 @@ +import { world } from '@server/game-server'; +import { Player } from '@server/world/actor/player/player'; +import { logger } from '@runejs/logger/dist/logger'; +import { Shop, shopItemContainer } from '@server/world/config/shops'; +import { widgets } from '@server/world/config/widget'; + +function findShop(identification: string): Shop { + return world.shops.find(shop => shop.identification === identification); +} + +export function openShop(player: Player, identification: string, closeOnWalk: boolean = true): void { + if(player.busy) { + return; + } + + const openedShop = findShop(identification); + if(!openedShop) { + logger.error(`Unable to find the shop with identification of: ${identification}`); + return; + } + + const shopContainer = shopItemContainer(openedShop); + + player.metadata['lastOpenedShop'] = openedShop; + player.outgoingPackets.updateWidgetString(widgets.shop.widgetId, widgets.shop.title, openedShop.name); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.shop, shopContainer); + player.outgoingPackets.sendUpdateAllWidgetItems(widgets.shopPlayerInventory, player.inventory); + + player.activeWidget = { + widgetId: widgets.shop.widgetId, + secondaryWidgetId: widgets.shopPlayerInventory.widgetId, + type: 'SCREEN_AND_TAB', + closeOnWalk: closeOnWalk + }; +} diff --git a/src/world/mob/player/action/swap-item-action.ts b/src/world/actor/player/action/swap-item-action.ts similarity index 56% rename from src/world/mob/player/action/swap-item-action.ts rename to src/world/actor/player/action/swap-item-action.ts index f019af456..95c93bd98 100644 --- a/src/world/mob/player/action/swap-item-action.ts +++ b/src/world/actor/player/action/swap-item-action.ts @@ -1,8 +1,8 @@ import { Player } from '../player'; -import { widgetIds } from '../widget'; +import { widgets } from '../../../config/widget'; -export const swapItemAction = (player: Player, fromSlot: number, toSlot: number, widgetId: number) => { - if(widgetId === widgetIds.inventory) { +export const swapItemAction = (player: Player, fromSlot: number, toSlot: number, widget: { widgetId: number, containerId: number }) => { + if(widget.widgetId === widgets.inventory.widgetId && widget.containerId === widgets.inventory.containerId) { const inventory = player.inventory; if(toSlot > inventory.size - 1 || fromSlot > inventory.size - 1) { diff --git a/src/world/actor/player/action/widget-action.ts b/src/world/actor/player/action/widget-action.ts new file mode 100644 index 000000000..833915194 --- /dev/null +++ b/src/world/actor/player/action/widget-action.ts @@ -0,0 +1,94 @@ +import { Player } from '@server/world/actor/player/player'; +import { pluginFilter } from '@server/plugins/plugin-loader'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; + +/** + * The definition for a widget action function. + */ +export type widgetAction = (details: WidgetActionDetails) => void; + +/** + * Details about a widget action. + */ +export interface WidgetActionDetails { + // The player performing the action. + player: Player; + // The ID of the UI widget that the button is on. + widgetId: number; + // The ID of the interacted child within the UI widget. + childId: number; + // The selected context menu option index. + optionId: number; +} + +/** + * Defines a widget interaction plugin. + */ +export interface WidgetActionPlugin extends ActionPlugin { + // A single UI widget ID or a list of widget IDs that this action applies to. + widgetIds: number | number[]; + // A single UI widget child ID or a list of child IDs that this action applies to. + childIds?: number | number[]; + // The context menu option index for this action. + optionId?: number; + // The action function to be performed. + action: widgetAction; + // Whether or not this item action should cancel other running or queued actions. + cancelActions?: boolean; +} + +/** + * A directory of all button interaction plugins. + */ +let widgetInteractions: WidgetActionPlugin[] = [ +]; + +/** + * Sets the list of widget interaction plugins. + * @param plugins The plugin list. + */ +export const setWidgetPlugins = (plugins: ActionPlugin[]): void => { + widgetInteractions = plugins as WidgetActionPlugin[]; +}; + +export const widgetAction = (player: Player, widgetId: number, childId: number, optionId: number): void => { + // Find all item on item action plugins that match this action + let interactionActions = widgetInteractions.filter(plugin => { + if(!questFilter(player, plugin)) { + return false; + } + + if(!pluginFilter(plugin.widgetIds, widgetId)) { + return false; + } + + if(plugin.optionId !== undefined && plugin.optionId !== optionId) { + return false; + } + + if(plugin.childIds !== undefined) { + return pluginFilter(plugin.childIds, childId); + } + + return true; + }); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled widget option: ${widgetId}, ${childId}:${optionId}`); + return; + } + + // Immediately run the plugins + interactionActions.forEach(plugin => { + if(plugin.cancelActions) { + player.actionsCancelled.next(); + } + + plugin.action({ player, widgetId, childId, optionId }); + }); +}; diff --git a/src/world/actor/player/action/world-item-action.ts b/src/world/actor/player/action/world-item-action.ts new file mode 100644 index 000000000..d028de16a --- /dev/null +++ b/src/world/actor/player/action/world-item-action.ts @@ -0,0 +1,103 @@ +import { Player } from '@server/world/actor/player/player'; +import { walkToAction } from '@server/world/actor/player/action/action'; +import { basicNumberFilter, basicStringFilter } from '@server/plugins/plugin-loader'; +import { logger } from '@runejs/logger/dist/logger'; +import { ActionPlugin, questFilter } from '@server/plugins/plugin'; +import { WorldItem } from '@server/world/items/world-item'; + +/** + * The definition for a world item action function. + */ +export type worldItemAction = (details: WorldItemActionDetails) => void; + +/** + * Details about a world item being interacted with. + */ +export interface WorldItemActionDetails { + // The player performing the action. + player: Player; + // The world item that the player is interacting with. + worldItem: WorldItem; +} + +/** + * Defines an world item interaction plugin. + */ +export interface WorldItemActionPlugin extends ActionPlugin { + // A single game item ID or a list of item IDs that this action applies to. + itemIds?: number | number[]; + // A single option name or a list of option names that this action applies to. + options: string | string[]; + // Whether or not the player needs to walk to this world item before performing the action. + walkTo: boolean; + // The action function to be performed. + action: worldItemAction; +} + +/** + * A directory of all world item interaction plugins. + */ +let worldItemInteractions: WorldItemActionPlugin[] = [ +]; + +/** + * Sets the list of world item interaction plugins. + * @param plugins The plugin list. + */ +export const setWorldItemPlugins = (plugins: ActionPlugin[]): void => { + worldItemInteractions = plugins as WorldItemActionPlugin[]; +}; + +// @TODO priority and cancelling other (lower priority) actions +export const worldItemAction = (player: Player, worldItem: WorldItem, option: string): void => { + if(player.busy) { + return; + } + + // Find all world item action plugins that reference this world item + let interactionActions = worldItemInteractions.filter(plugin => { + if(!questFilter(player, plugin)) { + return false; + } + + if(plugin.itemIds !== undefined) { + if(!basicNumberFilter(plugin.itemIds, worldItem.itemId)) { + return false; + } + } + + if(!basicStringFilter(plugin.options, option)) { + return false; + } + + return true; + }); + const questActions = interactionActions.filter(plugin => plugin.questAction !== undefined); + + if(questActions.length !== 0) { + interactionActions = questActions; + } + + if(interactionActions.length === 0) { + player.outgoingPackets.chatboxMessage(`Unhandled world item interaction: ${option} ${worldItem.itemId}`); + return; + } + + player.actionsCancelled.next(); + + // Separate out walk-to actions from immediate actions + const walkToPlugins = interactionActions.filter(plugin => plugin.walkTo); + const immediatePlugins = interactionActions.filter(plugin => !plugin.walkTo); + + // Make sure we walk to the NPC before running any of the walk-to plugins + if(walkToPlugins.length !== 0) { + walkToAction(player, worldItem.position) + .then(() => walkToPlugins.forEach(plugin => plugin.action({ player, worldItem }))) + .catch(() => logger.warn(`Unable to complete walk-to action.`)); + } + + // Immediately run any non-walk-to plugins + if(immediatePlugins.length !== 0) { + immediatePlugins.forEach(plugin => plugin.action({ player, worldItem })); + } +}; diff --git a/src/world/mob/player/player-data.ts b/src/world/actor/player/player-data.ts similarity index 66% rename from src/world/mob/player/player-data.ts rename to src/world/actor/player/player-data.ts index 0ba9e8db2..89bec6c6e 100644 --- a/src/world/mob/player/player-data.ts +++ b/src/world/actor/player/player-data.ts @@ -3,7 +3,13 @@ import { writeFileSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { logger } from '@runejs/logger/dist/logger'; import { Player } from './player'; -import { SkillValue } from '@server/world/mob/skills'; +import { SkillValue } from '@server/world/actor/skills'; + +export interface QuestProgress { + questId: string; + stage: string; + attributes: { [key: string]: any }; +} export interface Appearance { gender: number; @@ -21,16 +27,19 @@ export interface Appearance { skinColor: number; } -export interface PlayerSettings { - musicVolume: number; - soundEffectVolume: number; - splitPrivateChatEnabled: boolean; - twoMouseButtonsEnabled: boolean; - screenBrightness: number; - chatEffectsEnabled: boolean; - acceptAidEnabled: boolean; - runEnabled: boolean; - autoRetaliateEnabled: boolean; +export class PlayerSettings { + musicVolume: number = 0; + soundEffectVolume: number = 0; + areaEffectVolume: number = 0; + splitPrivateChatEnabled: boolean = false; + twoMouseButtonsEnabled: boolean = true; + screenBrightness: number = 2; + chatEffectsEnabled: boolean = true; + acceptAidEnabled: boolean = true; + runEnabled: boolean = false; + autoRetaliateEnabled: boolean = true; + attackStyle: number = 0; + bankInsertMode: number = 0; } export interface PlayerSave { @@ -50,18 +59,20 @@ export interface PlayerSave { equipment: Item[]; skills: SkillValue[]; settings: PlayerSettings; + savedMetadata: { [key: string]: any }; + quests: QuestProgress[]; } export const defaultAppearance = (): Appearance => { return { gender: 0, head: 0, - torso: 10, + torso: 18, arms: 26, legs: 36, hands: 33, feet: 42, - facialHair: 18, + facialHair: 10, hairColor: 0, torsoColor: 0, legColor: 0, @@ -71,17 +82,22 @@ export const defaultAppearance = (): Appearance => { }; export const defaultSettings = (): PlayerSettings => { - return { - musicVolume: 0, - soundEffectVolume: 0, - splitPrivateChatEnabled: false, - twoMouseButtonsEnabled: false, - screenBrightness: 2, - chatEffectsEnabled: true, - acceptAidEnabled: true, - runEnabled: false, - autoRetaliateEnabled: true - } as PlayerSettings; + return new PlayerSettings(); +}; + +export const validateSettings = (player: Player): void => { + const existingKeys = Object.keys(player.settings); + const newSettings = new PlayerSettings(); + const newKeys = Object.keys(newSettings); + + if(newKeys.length === existingKeys.length) { + return; + } + + const missingKeys = newKeys.filter(key => existingKeys.indexOf(key) === -1); + for(const key of missingKeys) { + player.settings[key] = newSettings[key]; + } }; export function savePlayerData(player: Player): boolean { @@ -104,7 +120,9 @@ export function savePlayerData(player: Player): boolean { inventory: player.inventory.items, equipment: player.equipment.items, skills: player.skills.values, - settings: player.settings + settings: player.settings, + savedMetadata: player.savedMetadata, + quests: player.quests, }; try { diff --git a/src/world/actor/player/player.ts b/src/world/actor/player/player.ts new file mode 100644 index 000000000..ac64415f3 --- /dev/null +++ b/src/world/actor/player/player.ts @@ -0,0 +1,890 @@ +import { AddressInfo, Socket } from 'net'; +import { OutgoingPackets } from '../../../net/outgoing-packets'; +import { Isaac } from '@server/net/isaac'; +import { PlayerUpdateTask } from './updating/player-update-task'; +import { Actor } from '../actor'; +import { Position } from '@server/world/position'; +import { cache, serverConfig, world } from '@server/game-server'; +import { logger } from '@runejs/logger'; +import { + Appearance, + defaultAppearance, defaultSettings, + loadPlayerSave, + PlayerSave, PlayerSettings, QuestProgress, + savePlayerData +} from './player-data'; +import { PlayerWidget, widgets, widgetScripts } from '../../config/widget'; +import { ContainerUpdateEvent, ItemContainer } from '../../items/item-container'; +import { EquipmentBonuses, ItemDetails } from '../../config/item-data'; +import { Item } from '../../items/item'; +import { Npc } from '../npc/npc'; +import { NpcUpdateTask } from './updating/npc-update-task'; +import { Subject } from 'rxjs'; +import { Chunk, ChunkUpdateItem } from '@server/world/map/chunk'; +import { QuadtreeKey } from '@server/world/world'; +import { daysSinceLastLogin } from '@server/util/time'; +import { itemIds } from '@server/world/config/item-ids'; +import { dialogueAction } from '@server/world/actor/player/action/dialogue-action'; +import { ActionPlugin } from '@server/plugins/plugin'; +import { songs } from '@server/world/config/songs'; +import { colors, hexToRgb, rgbTo16Bit } from '@server/util/colors'; +import { quests } from '@server/world/config/quests'; +import { ItemDefinition } from '@runejs/cache-parser'; + +const DEFAULT_TAB_WIDGET_IDS = [ + 92, widgets.skillsTab, 274, widgets.inventory.widgetId, widgets.equipment.widgetId, 271, 192, -1, 131, 148, + widgets.logoutTab, widgets.settingsTab, widgets.emotesTab, 239 +]; + +export enum Rights { + ADMIN = 2, + MOD = 1, + USER = 0 +} + +let playerInitPlugins: PlayerInitPlugin[]; + +export type playerInitAction = (details: { player: Player }) => void; + +export const setPlayerInitPlugins = (plugins: ActionPlugin[]): void => { + playerInitPlugins = plugins as PlayerInitPlugin[]; +}; + +export interface PlayerInitPlugin extends ActionPlugin { + // The action function to be performed. + action: playerInitAction; +} + +/** + * A player character within the game world. + */ +export class Player extends Actor { + + private readonly _socket: Socket; + private readonly _inCipher: Isaac; + private readonly _outCipher: Isaac; + public readonly clientUuid: number; + public readonly username: string; + private readonly password: string; + private _rights: Rights; + private loggedIn: boolean; + private _loginDate: Date; + private _lastAddress: string; + public isLowDetail: boolean; + private firstTimePlayer: boolean; + private readonly _outgoingPackets: OutgoingPackets; + public readonly playerUpdateTask: PlayerUpdateTask; + public readonly npcUpdateTask: NpcUpdateTask; + public trackedPlayers: Player[]; + public trackedNpcs: Npc[]; + private _appearance: Appearance; + private _activeWidget: PlayerWidget; + private queuedWidgets: PlayerWidget[]; + private readonly _equipment: ItemContainer; + private _bonuses: EquipmentBonuses; + private _carryWeight: number; + private _settings: PlayerSettings; + public readonly dialogueInteractionEvent: Subject; + public readonly numericInputEvent: Subject; + private _walkingTo: Position; + private _nearbyChunks: Chunk[]; + public readonly actionsCancelled: Subject; + private quadtreeKey: QuadtreeKey = null; + public savedMetadata: { [key: string]: any } = {}; + public quests: QuestProgress[] = []; + + public constructor(socket: Socket, inCipher: Isaac, outCipher: Isaac, clientUuid: number, username: string, password: string, isLowDetail: boolean) { + super(); + this._socket = socket; + this._inCipher = inCipher; + this._outCipher = outCipher; + this.clientUuid = clientUuid; + this.username = username; + this.password = password; + this._rights = Rights.ADMIN; + this.isLowDetail = isLowDetail; + this._outgoingPackets = new OutgoingPackets(this); + this.playerUpdateTask = new PlayerUpdateTask(this); + this.npcUpdateTask = new NpcUpdateTask(this); + this.trackedPlayers = []; + this.trackedNpcs = []; + this._activeWidget = null; + this.queuedWidgets = []; + this._carryWeight = 0; + this._equipment = new ItemContainer(14); + this.dialogueInteractionEvent = new Subject(); + this.numericInputEvent = new Subject(); + this._nearbyChunks = []; + this.actionsCancelled = new Subject(); + + this.loadSaveData(); + } + + private loadSaveData(): void { + const playerSave: PlayerSave = loadPlayerSave(this.username); + const firstTimePlayer: boolean = playerSave === null; + this.firstTimePlayer = firstTimePlayer; + + if(!firstTimePlayer) { + if(playerSave.savedMetadata) { + this.savedMetadata = playerSave.savedMetadata; + } + + // Existing player logging in + this.position = new Position(playerSave.position.x, playerSave.position.y, playerSave.position.level); + if(playerSave.inventory && playerSave.inventory.length !== 0) { + this.inventory.setAll(playerSave.inventory); + } + if(playerSave.equipment && playerSave.equipment.length !== 0) { + this.equipment.setAll(playerSave.equipment); + } + if(playerSave.skills && playerSave.skills.length !== 0) { + this.skills.values = playerSave.skills; + } + this._appearance = playerSave.appearance; + this._settings = playerSave.settings; + this._rights = playerSave.rights || Rights.USER; + + const lastLogin = playerSave.lastLogin?.date; + if(!lastLogin) { + this._loginDate = new Date(); + } else { + this._loginDate = new Date(lastLogin); + } + + if(playerSave.quests) { + this.quests = playerSave.quests; + } + + this._lastAddress = playerSave.lastLogin?.address || (this._socket?.address() as AddressInfo)?.address || '127.0.0.1'; + } else { + // Brand new player logging in + this.position = new Position(3222, 3222); + this.inventory.add({itemId: 1351, amount: 1}); + this.inventory.add({itemId: 1048, amount: 1}); + this.inventory.add({itemId: 6623, amount: 1}); + this.inventory.add({itemId: 1079, amount: 1}); + this.inventory.add({itemId: 1127, amount: 1}); + this.inventory.add({itemId: 1303, amount: 1}); + this.inventory.add({itemId: 1319, amount: 1}); + this.inventory.add({itemId: 1201, amount: 1}); + this._appearance = defaultAppearance(); + this._rights = Rights.USER; + this.savedMetadata = {}; + } + + if(!this._settings) { + this._settings = defaultSettings(); + } + } + + public init(): void { + this.loggedIn = true; + this.updateFlags.mapRegionUpdateRequired = true; + this.updateFlags.appearanceUpdateRequired = true; + + const playerChunk = world.chunkManager.getChunkForWorldPosition(this.position); + playerChunk.addPlayer(this); + + this.outgoingPackets.updateCurrentMapChunk(); + this.chunkChanged(playerChunk); + this.outgoingPackets.chatboxMessage('Welcome to RuneJS #435.'); + + DEFAULT_TAB_WIDGET_IDS.forEach((widgetId: number, tabIndex: number) => { + if(widgetId !== -1) { + this.outgoingPackets.sendTabWidget(tabIndex, widgetId); + } + }); + + this.skills.values.forEach((skill, index) => this.outgoingPackets.updateSkill(index, skill.level, skill.exp)); + + this.outgoingPackets.sendUpdateAllWidgetItems(widgets.inventory, this.inventory); + this.outgoingPackets.sendUpdateAllWidgetItems(widgets.equipment, this.equipment); + + if(this.firstTimePlayer) { + this.activeWidget = { + widgetId: widgets.characterDesign, + type: 'SCREEN', + disablePlayerMovement: true + }; + } else if(serverConfig.showWelcome) { + const daysSinceLogin = daysSinceLastLogin(this.loginDate); + let loginDaysStr = ''; + + if(daysSinceLogin <= 0) { + loginDaysStr = 'earlier today'; + } else if(daysSinceLogin === 1) { + loginDaysStr = 'yesterday'; + } else { + loginDaysStr = daysSinceLogin + ' days ago'; + } + this.outgoingPackets.updateWidgetString(widgets.welcomeScreenChildren.question, 1, `Want to help RuneJS improve?\\nSend us a pull request over on Github!`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 13, `You last logged in @red@${loginDaysStr}@bla@ from: @red@${this.lastAddress}`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 16, `You have @yel@0 unread messages\\nin your message centre.`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 14, `\\nYou have not yet set any recovery questions.\\nIt is @lre@strongly@yel@ recommended that you do so.\\n\\nIf you don't you will be @lre@unable to recover your\\n@lre@password@yel@ if you forget it, or it is stolen.`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 22, `To change your recovery questions:\\n1) Logout and return to the frontpage of this website.\\n2) Choose 'Set new recovery questions'.`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 17, `\\nYou do not have a Bank PIN.\\nPlease visit a bank if you would like one.`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 21, `To start a subscripton:\\n1) Logout and return to the frontpage of this website.\\n2) Choose 'Start a new subscription'`); + this.outgoingPackets.updateWidgetString(widgets.welcomeScreen, 19, `You are not a member.\\n\\nChoose to subscribe and\\nyou'll get loads of extra\\nbenefits and features.`); + + this.activeWidget = { + widgetId: widgets.welcomeScreen, + secondaryWidgetId: widgets.welcomeScreenChildren.question, + type: 'FULLSCREEN' + }; + } + + this.updateBonuses(); + this.updateCarryWeight(true); + this.modifyWidget(widgets.musicPlayerTab, { childId: 82, textColor: colors.green }); // Set "Harmony" to green/unlocked on the music tab + this.playSong(songs.harmony); + this.updateQuestTab(); + + this.inventory.containerUpdated.subscribe(event => this.inventoryUpdated(event)); + + this.actionsCancelled.subscribe(doNotCloseWidgets => { + if(!doNotCloseWidgets) { + this.outgoingPackets.closeActiveWidgets(); + this._activeWidget = null; + } + }); + + this._loginDate = new Date(); + this._lastAddress = (this._socket?.address() as AddressInfo)?.address || '127.0.0.1'; + + new Promise(resolve => { + playerInitPlugins.forEach(plugin => plugin.action({ player: this })); + resolve(); + }).then(() => { + this.outgoingPackets.flushQueue(); + logger.info(`${this.username}:${this.worldIndex} has logged in.`); + }); + } + + public logout(): void { + if(!this.loggedIn) { + return; + } + + world.playerTree.remove(this.quadtreeKey); + savePlayerData(this); + + this.outgoingPackets.logout(); + world.chunkManager.getChunkForWorldPosition(this.position).removePlayer(this); + world.deregisterPlayer(this); + this.loggedIn = false; + + logger.info(`${this.username} has logged out.`); + } + + /** + * Should be fired whenever the player's chunk changes. This will fire off chunk updates for all chunks not + * already tracked by the player - all the new chunks that are coming into view. + * @param chunk The player's new active map chunk. + */ + public chunkChanged(chunk: Chunk): void { + const nearbyChunks = world.chunkManager.getSurroundingChunks(chunk); + if(this._nearbyChunks.length === 0) { + this.sendChunkUpdates(nearbyChunks); + } else { + const newChunks = nearbyChunks.filter(c1 => this._nearbyChunks.findIndex(c2 => c1.equals(c2)) === -1); + this.sendChunkUpdates(newChunks); + } + + this._nearbyChunks = nearbyChunks; + } + + /** + * Sends chunk updates to notify the client of added & removed location objects + * @param chunks The chunks to update. + */ + private sendChunkUpdates(chunks: Chunk[]): void { + chunks.forEach(chunk => { + this.outgoingPackets.clearChunk(chunk); + + const chunkUpdateItems: ChunkUpdateItem[] = []; + + if(chunk.removedLocationObjects.size !== 0) { + chunk.removedLocationObjects.forEach(object => chunkUpdateItems.push({ object, type: 'REMOVE' })); + } + + if(chunk.addedLocationObjects.size !== 0) { + chunk.addedLocationObjects.forEach(object => chunkUpdateItems.push({ object, type: 'ADD' })); + } + + if(chunk.worldItems.size !== 0) { + chunk.worldItems.forEach(worldItemList => { + if(worldItemList && worldItemList.length !== 0) { + worldItemList.forEach(worldItem => { + if(!worldItem.initiallyVisibleTo || worldItem.initiallyVisibleTo.equals(this)) { + chunkUpdateItems.push({worldItem, type: 'ADD'}); + } + }); + } + }); + } + + if(chunkUpdateItems.length !== 0) { + this.outgoingPackets.updateChunk(chunk, chunkUpdateItems); + } + }); + } + + public async tick(): Promise { + return new Promise(resolve => { + this.walkingQueue.process(); + + if(this.updateFlags.mapRegionUpdateRequired) { + this.outgoingPackets.updateCurrentMapChunk(); + } + + resolve(); + }); + } + + public async update(): Promise { + await Promise.all([ this.playerUpdateTask.execute(), this.npcUpdateTask.execute() ]); + } + + public async reset(): Promise { + return new Promise(resolve => { + this.updateFlags.reset(); + + this.outgoingPackets.flushQueue(); + + if(this.metadata['updateChunk']) { + const { newChunk, oldChunk } = this.metadata['updateChunk']; + oldChunk.removePlayer(this); + newChunk.addPlayer(this); + this.chunkChanged(newChunk); + this.metadata['updateChunk'] = null; + } + + if(this.metadata['teleporting']) { + this.metadata['teleporting'] = null; + } + + resolve(); + }); + } + + /** + * Updates the player's quest tab progress. + */ + private updateQuestTab(): void { + this.outgoingPackets.updateClientConfig(widgetScripts.questPoints, this.getQuestPoints()); + + Object.keys(quests).forEach(questKey => { + const questData = quests[questKey]; + const playerQuest = this.quests.find(quest => quest.questId === questData.id); + let stage = 'NOT_STARTED'; + let color = colors.red; + if(playerQuest && playerQuest.stage) { + stage = playerQuest.stage; + color = stage === 'COMPLETE' ? colors.green : colors.yellow; + } + + this.modifyWidget(widgets.questTab, { childId: questData.questTabId, textColor: color }); + }); + } + + /** + * Fetches the player's number of quest points based off of their completed quests. + */ + public getQuestPoints(): number { + let questPoints = 0; + + if(this.quests && this.quests.length !== 0) { + this.quests.filter(quest => quest.stage === 'COMPLETE') + .forEach(quest => questPoints += quests[quest.questId].points); + } + + return questPoints; + } + /** + * Fetches a player's quest progression details. + * @param questId The ID of the quest to find the player's status on. + */ + public getQuest(questId: string): QuestProgress { + let playerQuest = this.quests.find(quest => quest.questId === questId); + if(!playerQuest) { + playerQuest = { + questId, + stage: 'NOT_STARTED', + attributes: {} + }; + + this.quests.push(playerQuest); + } + + return playerQuest; + } + + /** + * Sets a player's quest stage to the specified value. + * @param questId The ID of the quest to set the stage of. + * @param stage The stage to set the quest to. + */ + public setQuestStage(questId: string, stage: string): void { + const questData = quests[questId]; + + let playerQuest = this.quests.find(quest => quest.questId === questId); + if(!playerQuest) { + playerQuest = { + questId, + stage: 'NOT_STARTED', + attributes: {} + }; + + this.quests.push(playerQuest); + } + + if(playerQuest.stage === 'NOT_STARTED' && stage !== 'COMPLETE') { + this.modifyWidget(widgets.questTab, { childId: questData.questTabId, textColor: colors.yellow }); + } else if(playerQuest.stage !== 'COMPLETE' && stage === 'COMPLETE') { + this.outgoingPackets.updateClientConfig(widgetScripts.questPoints, questData.points + this.getQuestPoints()); + this.modifyWidget(widgets.questReward, { childId: 2, text: `You have completed ${questData.name}!` }); + this.modifyWidget(widgets.questReward, { childId: 8, text: `${questData.points} Quest Point${questData.points > 1 ? 's' : ''}` }); + + for(let i = 0; i < 5; i++) { + if(i >= questData.completion.rewards.length) { + this.modifyWidget(widgets.questReward, { childId: 9 + i, text: '' }); + } else { + this.modifyWidget(widgets.questReward, { childId: 9 + i, text: questData.completion.rewards[i] }); + } + } + + if(questData.completion.itemId) { + this.outgoingPackets.updateWidgetModel1(widgets.questReward, 3, + (cache.itemDefinitions.get(questData.completion.itemId) as ItemDefinition).inventoryModelId); + } else if(questData.completion.modelId) { + this.outgoingPackets.updateWidgetModel1(widgets.questReward, 3, questData.completion.modelId); + } + + this.outgoingPackets.setWidgetModelRotationAndZoom(widgets.questReward, 3, + questData.completion.modelRotationX || 0, questData.completion.modelRotationY || 0, + questData.completion.modelZoom || 0); + + this.activeWidget = { + widgetId: widgets.questReward, + type: 'SCREEN', + closeOnWalk: true + }; + + this.modifyWidget(widgets.questTab, { childId: questData.questTabId, textColor: colors.green }); + + questData.completion.onComplete(this); + } + + playerQuest.stage = stage; + } + + /** + * Modifies the specified widget using the provided options. + * @param widgetId The widget id of the widget to modify. + * @param options The options with which to modify the widget. + */ + public modifyWidget(widgetId: number, options: { childId?: number, text?: string, hidden?: boolean, textColor?: number }): void { + const { childId, text, hidden, textColor } = options; + + if(childId !== undefined) { + if(text !== undefined) { + this.outgoingPackets.updateWidgetString(widgetId, childId, text); + } + if(hidden !== undefined) { + this.outgoingPackets.toggleWidgetVisibility(widgets.skillGuide, childId, hidden); + } + if(textColor !== undefined) { + const { r, g, b } = hexToRgb(textColor); + this.outgoingPackets.updateWidgetColor(widgetId, childId, rgbTo16Bit(r, g, b)); + } + } + } + + /** + * Plays the given song for the player. + * @param songId The id of the song to play. + */ + public playSong(songId: number): void { + this.outgoingPackets.playSong(songId); + } + + /** + * Plays a sound for this specific player. + * @param soundId The id of the sound effect. + * @param volume The volume to play the sound at; defaults to 10 (max). + * @param delay The delay after which to play the sound; defaults to 0 (no delay). + */ + public playSound(soundId: number, volume: number = 10, delay: number = 0): void { + this.outgoingPackets.playSound(soundId, volume, delay); + } + + /** + * Sends a message to the player via the chatbox. + * @param messages The single message or array of lines to send to the player. + * @param showDialogue Whether or not to show the message in a "Click to continue" dialogue. + * @returns A Promise that resolves when the player has clicked the "click to continue" button or + * after their chat messages have been sent. + */ + public async sendMessage(messages: string | string[], showDialogue: boolean = false): Promise { + if(!Array.isArray(messages)) { + messages = [ messages ]; + } + + if(!showDialogue) { + messages.forEach(message => this.outgoingPackets.chatboxMessage(message)); + return Promise.resolve(); + } else { + if(messages.length > 5) { + throw new Error(`Dialogues have a maximum of 5 lines!`); + } + + return dialogueAction(this, { type: 'TEXT', lines: messages }).then(async d => { + d.close(); + return Promise.resolve(); + }); + } + } + + /** + * Instantly teleports the player to the specified location. + * @param newPosition The player's new position. + */ + public teleport(newPosition: Position): void { + const oldChunk = world.chunkManager.getChunkForWorldPosition(this.position); + const newChunk = world.chunkManager.getChunkForWorldPosition(newPosition); + + this.walkingQueue.clear(); + this.position = newPosition; + + this.updateFlags.mapRegionUpdateRequired = true; + this.lastMapRegionUpdatePosition = newPosition; + this.metadata['teleporting'] = true; + + if(!oldChunk.equals(newChunk)) { + this.metadata['updateChunk'] = { newChunk, oldChunk }; + } + } + + public canMove(): boolean { + return true; + } + + public removeFirstItem(item: number | Item): number { + const slot = this.inventory.removeFirst(item); + + if(slot === -1) { + return -1; + } + + this.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, slot, null); + return slot; + } + + public hasCoins(amount: number): number { + return this.inventory.items + .findIndex(item => item !== null && item.itemId === itemIds.coins && item.amount >= amount); + } + + public removeItem(slot: number): void { + this.inventory.remove(slot); + + this.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, slot, null); + } + + public giveItem(item: number | Item): boolean { + const addedItem = this.inventory.add(item); + if(addedItem === null) { + return false; + } + + this.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, addedItem.slot, addedItem.item); + return true; + } + + public hasItemInEquipment(item: number | Item): boolean { + return this._equipment.has(item); + } + + public hasItemOnPerson(item: number | Item): boolean { + return this.hasItemInInventory(item) || this.hasItemInEquipment(item); + } + + private inventoryUpdated(event: ContainerUpdateEvent): void { + this.updateCarryWeight(); + } + + /** + * Updates the player's carry weight based off of their held items (inventory + equipment). + * @param force Whether or not to force send an updated carry weight to the game client. + */ + public updateCarryWeight(force: boolean = false): void { + const oldWeight = this._carryWeight; + this._carryWeight = Math.round(this.inventory.weight() + this.equipment.weight()); + + if(oldWeight !== this._carryWeight || force) { + this.outgoingPackets.updateCarryWeight(this._carryWeight); + } + } + + /** + * Updates a player's client settings based off of which setting button they've clicked. + * @param buttonId The ID of the setting button. + * @TODO refactor to better match the 400+ widget system + */ + public settingChanged(buttonId: number): void { + const settingsMappings = { + 0: {setting: 'runEnabled', value: !this.settings['runEnabled']}, + 1: {setting: 'chatEffectsEnabled', value: !this.settings['chatEffectsEnabled']}, + 2: {setting: 'splitPrivateChatEnabled', value: !this.settings['splitPrivateChatEnabled']}, + 3: {setting: 'twoMouseButtonsEnabled', value: !this.settings['twoMouseButtonsEnabled']}, + 4: {setting: 'acceptAidEnabled', value: !this.settings['acceptAidEnabled']}, + // 5 is house options + // 6 is unknown, might not even exist + 7: {setting: 'screenBrightness', value: 1}, + 8: {setting: 'screenBrightness', value: 2}, + 9: {setting: 'screenBrightness', value: 3}, + 10: {setting: 'screenBrightness', value: 4}, + 11: {setting: 'musicVolume', value: 4}, + 12: {setting: 'musicVolume', value: 3}, + 13: {setting: 'musicVolume', value: 2}, + 14: {setting: 'musicVolume', value: 1}, + 15: {setting: 'musicVolume', value: 0}, + 16: {setting: 'soundEffectVolume', value: 4}, + 17: {setting: 'soundEffectVolume', value: 3}, + 18: {setting: 'soundEffectVolume', value: 2}, + 19: {setting: 'soundEffectVolume', value: 1}, + 20: {setting: 'soundEffectVolume', value: 0}, + 29: {setting: 'areaEffectVolume', value: 4}, + 30: {setting: 'areaEffectVolume', value: 3}, + 31: {setting: 'areaEffectVolume', value: 2}, + 32: {setting: 'areaEffectVolume', value: 1}, + 33: {setting: 'areaEffectVolume', value: 0}, + // 150: {setting: 'autoRetaliateEnabled', value: true}, + // 151: {setting: 'autoRetaliateEnabled', value: false} + }; + + if(!settingsMappings.hasOwnProperty(buttonId)) { + return; + } + + const config = settingsMappings[buttonId]; + this.settings[config.setting] = config.value; + } + + /** + * Updates the player's combat bonuses based off of their equipped items. + */ + public updateBonuses(): void { + this.clearBonuses(); + + for(const item of this._equipment.items) { + if(item === null) { + continue; + } + + this.addBonuses(item); + } + } + + private addBonuses(item: Item): void { + const itemData: ItemDetails = world.itemData.get(item.itemId); + + if(!itemData || !itemData.equipment || !itemData.equipment.bonuses) { + return; + } + + const bonuses = itemData.equipment.bonuses; + + if(bonuses.offencive) { + [ 'speed', 'stab', 'slash', 'crush', 'magic', 'ranged' ].forEach(bonus => this._bonuses.offencive[bonus] += (!bonuses.offencive.hasOwnProperty(bonus) ? 0 : bonuses.offencive[bonus])); + } + + if(bonuses.defencive) { + [ 'stab', 'slash', 'crush', 'magic', 'ranged' ].forEach(bonus => this._bonuses.defencive[bonus] += (!bonuses.defencive.hasOwnProperty(bonus) ? 0 : bonuses.defencive[bonus])); + } + + if(bonuses.skill) { + [ 'strength', 'prayer' ].forEach(bonus => this._bonuses.skill[bonus] += (!bonuses.skill.hasOwnProperty(bonus) ? 0 : bonuses.skill[bonus])); + } + } + + private clearBonuses(): void { + this._bonuses = { + offencive: { + speed: 0, stab: 0, slash: 0, crush: 0, magic: 0, ranged: 0 + }, + defencive: { + stab: 0, slash: 0, crush: 0, magic: 0, ranged: 0 + }, + skill: { + strength: 0, prayer: 0 + } + }; + } + + /** + * Queues up a widget to be displayed when the active widget is closed. + * If there is no active widget, the provided widget will be automatically displayed. + * @param widget The widget to queue. + */ + public queueWidget(widget: PlayerWidget): void { + if(this.activeWidget === null) { + this.activeWidget = widget; + } else { + this.queuedWidgets.push(widget); + } + } + + public sendLogMessage(message: string, isConsole: boolean): void { + if(isConsole) { + this.outgoingPackets.consoleMessage(message); + } else { + this.outgoingPackets.chatboxMessage(message); + } + } + + /** + * Closes the currently active widget or widget pair. + * @param notifyClient [optional] Whether or not to notify the game client that widgets should be cleared. Defaults to true. + */ + public closeActiveWidgets(notifyClient: boolean = true): void { + if(notifyClient) { + if(this.queuedWidgets.length !== 0) { + this.activeWidget = this.queuedWidgets.shift(); + } else { + this.activeWidget = null; + } + } else { + this._activeWidget = null; + + if(this.queuedWidgets.length !== 0) { + this.activeWidget = this.queuedWidgets.shift(); + } else { + this.actionsCancelled.next(true); + } + } + } + + /** + * Checks to see if the player has the specified widget ID open on their screen or not. + * @param widgetId The ID of the widget to look for. + */ + public hasWidgetOpen(widgetId: number): boolean { + return this.activeWidget && this.activeWidget.widgetId === widgetId; + } + + public set position(position: Position) { + super.position = position; + + if(this.quadtreeKey !== null) { + world.playerTree.remove(this.quadtreeKey); + } + + this.quadtreeKey = { x: position.x, y: position.y, actor: this }; + world.playerTree.push(this.quadtreeKey); + } + + public get position(): Position { + return super.position; + } + + public equals(player: Player): boolean { + return this.worldIndex === player.worldIndex && this.username === player.username && this.clientUuid === player.clientUuid; + } + + public get socket(): Socket { + return this._socket; + } + + public get inCipher(): Isaac { + return this._inCipher; + } + + public get outCipher(): Isaac { + return this._outCipher; + } + + public get outgoingPackets(): OutgoingPackets { + return this._outgoingPackets; + } + + public get loginDate(): Date { + return this._loginDate; + } + + public get lastAddress(): string { + return this._lastAddress; + } + + public get rights(): Rights { + return this._rights; + } + + public get appearance(): Appearance { + return this._appearance; + } + + public set appearance(value: Appearance) { + this._appearance = value; + } + + public get activeWidget(): PlayerWidget { + return this._activeWidget; + } + + public set activeWidget(value: PlayerWidget) { + if(value !== null) { + if(value.beforeOpened !== undefined) { + value.beforeOpened(); + } + + if(value.type === 'SCREEN') { + this.outgoingPackets.showScreenWidget(value.widgetId); + } else if(value.type === 'CHAT') { + this.outgoingPackets.showChatboxWidget(value.widgetId); + } else if(value.type === 'FULLSCREEN') { + this.outgoingPackets.showFullscreenWidget(value.widgetId, value.secondaryWidgetId); + } else if(value.type === 'SCREEN_AND_TAB') { + this.outgoingPackets.showScreenAndTabWidgets(value.widgetId, value.secondaryWidgetId); + } + + if(value.afterOpened !== undefined) { + value.afterOpened(); + } + } else { + this.outgoingPackets.closeActiveWidgets(); + } + + this.actionsCancelled.next(true); + this._activeWidget = value; + } + + public get equipment(): ItemContainer { + return this._equipment; + } + + public get carryWeight(): number { + return this._carryWeight; + } + + public get settings(): PlayerSettings { + return this._settings; + } + + public get walkingTo(): Position { + return this._walkingTo; + } + + public set walkingTo(value: Position) { + this._walkingTo = value; + } + + public get nearbyChunks(): Chunk[] { + return this._nearbyChunks; + } + + public get bonuses(): EquipmentBonuses { + return this._bonuses; + } +} diff --git a/src/world/actor/player/updating/actor-updating.ts b/src/world/actor/player/updating/actor-updating.ts new file mode 100644 index 000000000..bbe9fdb84 --- /dev/null +++ b/src/world/actor/player/updating/actor-updating.ts @@ -0,0 +1,141 @@ +import { world } from '@server/game-server'; +import { Packet } from '@server/net/packet'; +import { Npc } from '@server/world/actor/npc/npc'; +import { Player } from '../player'; +import { Position } from '@server/world/position'; +import { QuadtreeKey } from '@server/world/world'; +import { Actor } from '@server/world/actor/actor'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +/** + * Handles the registration of nearby NPCs or Players for the specified player. + */ +export function registerNewActors(packet: Packet, player: Player, trackedActors: Actor[], nearbyActors: QuadtreeKey[], + registerActor: (actor: Actor) => void): void { + if(trackedActors.length >= 255) { + return; + } + + // We only want to send about 20 new actors at a time, to help save some memory and computing time + // Any remaining players or npcs will be automatically picked up by subsequent updates + let newActors: QuadtreeKey[] = nearbyActors.filter(m1 => !trackedActors.includes(m1.actor)); + if(newActors.length > 50) { + // We also sort the list of players or npcs here by how close they are to the current player if there are more than 80, so we can render the nearest first + newActors = newActors + .sort((a, b) => player.position.distanceBetween(a.actor.position) - player.position.distanceBetween(b.actor.position)) + .slice(0, 50); + } + + for(const newActor of newActors) { + const nearbyActor = newActor.actor; + + if(nearbyActor instanceof Player) { + if(player.equals(nearbyActor)) { + // Other player is actually this player! + continue; + } + + if(!world.playerExists(nearbyActor)) { + // Other player is no longer in the game world + continue; + } + } else if(nearbyActor instanceof Npc) { + if(!world.npcExists(nearbyActor)) { + // Npc is no longer in the game world + continue; + } + } + + if(trackedActors.findIndex(m => m.equals(nearbyActor)) !== -1) { + // Npc or other player is already tracked by this player + continue; + } + + if(!nearbyActor.position.withinViewDistance(player.position)) { + // Player or npc is still too far away to be worth rendering + // Also - values greater than 15 and less than -15 are too large, or too small, to be sent via 5 bits (max length of 32) + continue; + } + + // Only 255 players or npcs are able to be rendered at a time + // To help performance, we limit it to 200 here + if(trackedActors.length >= 255) { + return; + } + + registerActor(nearbyActor); + } +} + +/** + * Handles updating of nearby NPCs or Players for the specified player. + */ +export function updateTrackedActors(packet: Packet, playerPosition: Position, appendUpdateMaskData: (actor: Actor) => void, + trackedActors: Actor[], nearbyActors: QuadtreeKey[]): Actor[] { + packet.putBits(8, trackedActors.length); // Tracked actor count + + if(trackedActors.length === 0) { + return []; + } + + const existingTrackedActors: Actor[] = []; + + for(let i = 0; i < trackedActors.length; i++) { + const trackedActor: Actor = trackedActors[i]; + let exists = true; + + if(trackedActor instanceof Player) { + if(!world.playerExists(trackedActor as Player)) { + exists = false; + } + } else { + if(!world.npcExists(trackedActor as Npc)) { + exists = false; + } + } + + if(exists && nearbyActors.findIndex(m => m.actor.equals(trackedActor)) !== -1 + && trackedActor.position.withinViewDistance(playerPosition)) { + appendMovement(trackedActor, packet); + appendUpdateMaskData(trackedActor); + existingTrackedActors.push(trackedActor); + } else { + // De-register the actor if they are no longer nearby + packet.putBits(1, 1); + packet.putBits(2, 3); + } + } + + return existingTrackedActors; +} + +/** + * Applends movement data of a player or NPC to the specified updating packet. + */ +export function appendMovement(actor: Actor, packet: ByteBuffer): void { + if(actor.walkDirection !== -1) { + // Actor is walking/running + packet.putBits(1, 1); // Update required + + if(actor.runDirection === -1) { + // Actor is walking + packet.putBits(2, 1); // Actor walking + packet.putBits(3, actor.walkDirection); + } else { + // Actor is running + packet.putBits(2, 2); // Actor running + packet.putBits(3, actor.walkDirection); + packet.putBits(3, actor.runDirection); + } + + packet.putBits(1, actor.updateFlags.updateBlockRequired ? 1 : 0); // Whether or not an update flag block follows + } else { + // Did not move + if(actor.updateFlags.updateBlockRequired) { + packet.putBits(1, 1); // Update required + packet.putBits(2, 0); // Signify the player did not move + } else { + packet.putBits(1, 0); // No update required + } + } +} diff --git a/src/world/actor/player/updating/npc-update-task.ts b/src/world/actor/player/updating/npc-update-task.ts new file mode 100644 index 000000000..bec77555a --- /dev/null +++ b/src/world/actor/player/updating/npc-update-task.ts @@ -0,0 +1,154 @@ +import { Task } from '@server/task/task'; +import { Player } from '../player'; +import { Packet, PacketType } from '@server/net/packet'; +import { Npc } from '@server/world/actor/npc/npc'; +import { world } from '@server/game-server'; +import { registerNewActors, updateTrackedActors } from './actor-updating'; +import { ByteBuffer } from '@runejs/byte-buffer'; + +/** + * Handles the chonky npc updating packet for a specific player. + */ +export class NpcUpdateTask extends Task { + + private readonly player: Player; + + public constructor(player: Player) { + super(); + this.player = player; + } + + public async execute(): Promise { + return new Promise(resolve => { + const npcUpdatePacket: Packet = new Packet(128, PacketType.DYNAMIC_LARGE); + npcUpdatePacket.openBitBuffer(); + + const updateMaskData = new ByteBuffer(5000); + + const nearbyNpcs = world.npcTree.colliding({ + x: this.player.position.x - 15, + y: this.player.position.y - 15, + width: 32, + height: 32 + }); + + this.player.trackedNpcs = updateTrackedActors(npcUpdatePacket, this.player.position, + actor => this.appendUpdateMaskData(actor as Npc, updateMaskData), this.player.trackedNpcs, nearbyNpcs) as Npc[]; + + registerNewActors(npcUpdatePacket, this.player, this.player.trackedNpcs, nearbyNpcs, actor => { + const newNpc = actor as Npc; + const positionOffsetX = newNpc.position.x - this.player.position.x; + const positionOffsetY = newNpc.position.y - this.player.position.y; + + // Add npc to this player's list of tracked npcs + this.player.trackedNpcs.push(newNpc); + + // Notify the client of the new npc and their worldIndex + npcUpdatePacket.putBits(15, newNpc.worldIndex); + npcUpdatePacket.putBits(3, newNpc.faceDirection); + npcUpdatePacket.putBits(5, positionOffsetX); // World Position X axis offset relative to the player + npcUpdatePacket.putBits(5, positionOffsetY); // World Position Y axis offset relative to the player + npcUpdatePacket.putBits(1, newNpc.updateFlags.updateBlockRequired ? 1 : 0); // Update is required + npcUpdatePacket.putBits(1, 1); // Discard client walking queues + npcUpdatePacket.putBits(13, newNpc.id); + + this.appendUpdateMaskData(newNpc, updateMaskData); + }); + + if(updateMaskData.writerIndex !== 0) { + npcUpdatePacket.putBits(15, 32767); + npcUpdatePacket.closeBitBuffer(); + + npcUpdatePacket.putBytes(updateMaskData.flipWriter()); + } else { + // No npc updates were appended, so just end the packet here + npcUpdatePacket.closeBitBuffer(); + } + + this.player.outgoingPackets.queue(npcUpdatePacket, true); + resolve(); + }); + } + + private appendUpdateMaskData(npc: Npc, updateMaskData: ByteBuffer): void { + const updateFlags = npc.updateFlags; + if(!updateFlags.updateBlockRequired) { + return; + } + + let mask = 0; + + if(updateFlags.appearanceUpdateRequired) { + mask |= 0x80; + } + if(updateFlags.faceActor !== undefined) { + mask |= 0x4; + } + if(updateFlags.chatMessages.length !== 0) { + mask |= 0x40; + } + if(updateFlags.facePosition) { + mask |= 0x8; + } + if(updateFlags.animation) { + mask |= 0x10; + } + + updateMaskData.put(mask, 'BYTE'); + + if(updateFlags.faceActor !== undefined) { + const actor = updateFlags.faceActor; + + if(actor === null) { + // Reset faced actor + updateMaskData.put(65535, 'SHORT'); + } else { + let worldIndex = actor.worldIndex; + + if(actor instanceof Player) { + // Client checks if index is less than 32768. + // If it is, it looks for an NPC. + // If it isn't, it looks for a player (subtracting 32768 to find the index). + worldIndex += 32768 + 1; + } + + updateMaskData.put(worldIndex, 'SHORT'); + } + } + + if(updateFlags.chatMessages.length !== 0) { + const message = updateFlags.chatMessages[0]; + + if(message.message) { + updateMaskData.putString(message.message); + } else { + updateMaskData.putString('Undefined Message'); + } + } + + if(updateFlags.appearanceUpdateRequired) { + updateMaskData.put(npc.id, 'SHORT'); + } + + if(updateFlags.facePosition) { + const position = updateFlags.facePosition; + updateMaskData.put(position.x * 2 + 1, 'SHORT'); + updateMaskData.put(position.y * 2 + 1, 'SHORT', 'LITTLE_ENDIAN'); + } + + if(updateFlags.animation) { + const animation = updateFlags.animation; + + if(animation === null || animation.id === -1) { + // Reset animation + updateMaskData.put(65535, 'SHORT'); + updateMaskData.put(0); + } else { + const delay = updateFlags.animation.delay || 0; + updateMaskData.put(animation.id, 'SHORT'); + updateMaskData.put(delay); + } + } + } + +} diff --git a/src/world/actor/player/updating/player-update-task.ts b/src/world/actor/player/updating/player-update-task.ts new file mode 100644 index 000000000..00b9a915c --- /dev/null +++ b/src/world/actor/player/updating/player-update-task.ts @@ -0,0 +1,302 @@ +import { Player } from '../player'; +import { Task } from '@server/task/task'; +import { UpdateFlags } from '@server/world/actor/update-flags'; +import { Packet, PacketType } from '@server/net/packet'; +import { world } from '@server/game-server'; +import { EquipmentSlot, HelmetType, ItemDetails, TorsoType } from '@server/world/config/item-data'; +import { ItemContainer } from '@server/world/items/item-container'; +import { appendMovement, updateTrackedActors, registerNewActors } from './actor-updating'; +import { ByteBuffer } from '@runejs/byte-buffer'; +import { stringToLong } from '@server/util/strings'; + +/** + * Handles the chonky player updating packet. + */ +export class PlayerUpdateTask extends Task { + + private readonly player: Player; + + public constructor(player: Player) { + super(); + this.player = player; + } + + public async execute(): Promise { + return new Promise(resolve => { + const updateFlags: UpdateFlags = this.player.updateFlags; + const playerUpdatePacket: Packet = new Packet(92, PacketType.DYNAMIC_LARGE); + playerUpdatePacket.openBitBuffer(); + + const updateMaskData = new ByteBuffer(5000); + + if(updateFlags.mapRegionUpdateRequired || this.player.metadata['teleporting']) { + playerUpdatePacket.putBits(1, 1); // Update Required + playerUpdatePacket.putBits(2, 3); // Map Region changed + playerUpdatePacket.putBits(1, this.player.metadata['teleporting'] ? 1 : 0); // Whether or not the client should discard the current walking queue (1 if teleporting, 0 if not) + playerUpdatePacket.putBits(2, this.player.position.level); // Player Height + playerUpdatePacket.putBits(1, updateFlags.updateBlockRequired ? 1 : 0); // Whether or not an update flag block follows + playerUpdatePacket.putBits(7, this.player.position.chunkLocalX); // Player Local Chunk X + playerUpdatePacket.putBits(7, this.player.position.chunkLocalY); // Player Local Chunk Y + } else { + appendMovement(this.player, playerUpdatePacket); + } + + this.appendUpdateMaskData(this.player, updateMaskData, false); + + let nearbyPlayers = world.playerTree.colliding({ + x: this.player.position.x - 15, + y: this.player.position.y - 15, + width: 32, + height: 32 + }); + + if(nearbyPlayers.length > 200) { + nearbyPlayers = world.playerTree.colliding({ + x: this.player.position.x - 7, + y: this.player.position.y - 7, + width: 16, + height: 16 + }); + } + + this.player.trackedPlayers = updateTrackedActors(playerUpdatePacket, this.player.position, + actor => this.appendUpdateMaskData(actor as Player, updateMaskData), this.player.trackedPlayers, nearbyPlayers) as Player[]; + + registerNewActors(playerUpdatePacket, this.player, this.player.trackedPlayers, nearbyPlayers, actor => { + const newPlayer = actor as Player; + const positionOffsetX = newPlayer.position.x - this.player.position.x; + const positionOffsetY = newPlayer.position.y - this.player.position.y; + + // Add other player to this player's list of tracked players + this.player.trackedPlayers.push(newPlayer); + + // Notify the client of the new player and their worldIndex + playerUpdatePacket.putBits(11, newPlayer.worldIndex + 1); + + playerUpdatePacket.putBits(5, positionOffsetX); // World Position X axis offset relative to the main player + playerUpdatePacket.putBits(5, positionOffsetY); // World Position Y axis offset relative to the main player + playerUpdatePacket.putBits(3, newPlayer.faceDirection); + playerUpdatePacket.putBits(1, 1); // Update is required + playerUpdatePacket.putBits(1, 1); // Discard client walking queues + + this.appendUpdateMaskData(newPlayer, updateMaskData, true); + }); + + if(updateMaskData.writerIndex !== 0) { + playerUpdatePacket.putBits(11, 2047); + playerUpdatePacket.closeBitBuffer(); + + playerUpdatePacket.putBytes(updateMaskData.flipWriter()); + } else { + // No player updates were appended, so just end the packet here + playerUpdatePacket.closeBitBuffer(); + } + + this.player.outgoingPackets.queue(playerUpdatePacket, true); + resolve(); + }); + } + + private appendUpdateMaskData(player: Player, updateMaskData: ByteBuffer, forceUpdate?: boolean): void { + const updateFlags = player.updateFlags; + + if(!updateFlags.updateBlockRequired && !forceUpdate) { + return; + } + + let mask: number = 0; + + if(updateFlags.appearanceUpdateRequired || forceUpdate) { + mask |= 0x20; + } + if(updateFlags.chatMessages.length !== 0) { + mask |= 0x8; + } + if(updateFlags.faceActor !== undefined) { + mask |= 0x4; + } + if(updateFlags.facePosition) { + mask |= 0x10; + } + if(updateFlags.graphics) { + mask |= 0x200; + } + if(updateFlags.animation !== undefined) { + mask |= 0x1; + } + + if(mask >= 0x100) { + mask |= 0x2; + updateMaskData.put(mask & 0xff); + updateMaskData.put(mask >> 8); + } else { + updateMaskData.put(mask); + } + + if(updateFlags.facePosition) { + const position = updateFlags.facePosition; + updateMaskData.put(position.x * 2 + 1, 'SHORT'); + updateMaskData.put(position.y * 2 + 1, 'SHORT', 'LITTLE_ENDIAN'); + } + + if(updateFlags.animation !== undefined) { + const animation = updateFlags.animation; + + if(animation === null || animation.id === -1) { + // Reset animation + updateMaskData.put(-1, 'SHORT', 'LITTLE_ENDIAN'); + updateMaskData.put(0, 'BYTE'); + } else { + const delay = updateFlags.animation.delay || 0; + updateMaskData.put(updateFlags.animation.id, 'SHORT', 'LITTLE_ENDIAN'); + updateMaskData.put(delay, 'BYTE'); + } + } + + if(updateFlags.faceActor !== undefined) { + const actor = updateFlags.faceActor; + + if(actor === null) { + // Reset faced actor + updateMaskData.put(65535, 'SHORT'); + } else { + let worldIndex = actor.worldIndex; + + if(actor instanceof Player) { + // Client checks if index is less than 32768. + // If it is, it looks for an NPC. + // If it isn't, it looks for a player (subtracting 32768 to find the index). + worldIndex += 32768 + 1; + } + + updateMaskData.put(worldIndex, 'SHORT'); + } + } + + if(updateFlags.chatMessages.length !== 0) { + const message = updateFlags.chatMessages[0]; + updateMaskData.put(((message.color & 0xFF) << 8) + (message.effects & 0xFF), 'SHORT'); + updateMaskData.put(player.rights.valueOf(), 'BYTE'); + updateMaskData.put(message.data.length, 'BYTE'); + for(let i = 0; i < message.data.length; i++) { + updateMaskData.put(message.data.readInt8(i), 'BYTE'); + } + } + + if(updateFlags.appearanceUpdateRequired || forceUpdate) { + const equipment = player.equipment; + const appearanceData = new ByteBuffer(500); + appearanceData.put(player.appearance.gender); // Gender + appearanceData.put(-1); // Skull Icon + appearanceData.put(-1); // Prayer Icon + + for(let i = 0; i < 4; i++) { + const item = equipment.items[i]; + + if(item) { + appearanceData.put(0x200 + item.itemId, 'SHORT'); + } else { + appearanceData.put(0); + } + } + + const torsoItem = equipment.items[EquipmentSlot.TORSO]; + let torsoItemData: ItemDetails = null; + if(torsoItem) { + torsoItemData = world.itemData.get(torsoItem.itemId); + appearanceData.put(0x200 + torsoItem.itemId, 'SHORT'); + } else { + appearanceData.put(0x100 + player.appearance.torso, 'SHORT'); + } + + const offHandItem = equipment.items[EquipmentSlot.OFF_HAND]; + if(offHandItem) { + appearanceData.put(0x200 + offHandItem.itemId, 'SHORT'); + } else { + appearanceData.put(0); + } + + if(torsoItemData && torsoItemData.equipment && torsoItemData.equipment.torsoType && torsoItemData.equipment.torsoType === TorsoType.FULL) { + appearanceData.put(0); + } else { + appearanceData.put(0x100 + player.appearance.arms, 'SHORT'); + } + + this.appendBasicAppearanceItem(appearanceData, equipment, player.appearance.legs, EquipmentSlot.LEGS); + + const headItem = equipment.items[EquipmentSlot.HEAD]; + let helmetType = null; + let fullHelmet = false; + + if(headItem) { + const headItemData = world.itemData.get(equipment.items[EquipmentSlot.HEAD].itemId); + + if(headItemData && headItemData.equipment && headItemData.equipment.helmetType) { + helmetType = headItemData.equipment.helmetType; + + if(helmetType === HelmetType.FULL_HELMET) { + fullHelmet = true; + } + } + } + + if(!helmetType || helmetType === HelmetType.HAT) { + appearanceData.put(0x100 + player.appearance.head, 'SHORT'); + } else { + appearanceData.put(0); + } + + this.appendBasicAppearanceItem(appearanceData, equipment, player.appearance.hands, EquipmentSlot.GLOVES); + this.appendBasicAppearanceItem(appearanceData, equipment, player.appearance.feet, EquipmentSlot.BOOTS); + + if(player.appearance.gender === 1 || fullHelmet) { + appearanceData.put(0); + } else { + appearanceData.put(0x100 + player.appearance.facialHair, 'SHORT'); + } + + [ + player.appearance.hairColor, + player.appearance.torsoColor, + player.appearance.legColor, + player.appearance.feetColor, + player.appearance.skinColor, + ].forEach(color => appearanceData.put(color)); + + [ + 0x328, // stand + 0x337, // stand turn + 0x333, // walk + 0x334, // turn 180 + 0x335, // turn 90 + 0x336, // turn 90 reverse + 0x338, // run + ].forEach(animationId => appearanceData.put(animationId, 'SHORT')); + + appearanceData.put(stringToLong(player.username), 'LONG'); // Username + appearanceData.put(3); // Combat Level + appearanceData.put(0, 'SHORT'); // Skill Level (Total Level) + + const appearanceDataSize = appearanceData.writerIndex; + + updateMaskData.put(appearanceDataSize); + updateMaskData.putBytes(appearanceData.flipWriter()); + } + + if(updateFlags.graphics) { + const delay = updateFlags.graphics.delay || 0; + updateMaskData.put(updateFlags.graphics.id, 'SHORT', 'LITTLE_ENDIAN'); + updateMaskData.put(updateFlags.graphics.height << 16 | delay & 0xffff, 'INT'); + } + } + + private appendBasicAppearanceItem(buffer: ByteBuffer, equipment: ItemContainer, appearanceInfo: number, equipmentSlot: EquipmentSlot): void { + const item = equipment.items[equipmentSlot]; + if(item) { + buffer.put(0x200 + item.itemId, 'SHORT'); + } else { + buffer.put(0x100 + appearanceInfo, 'SHORT'); + } + } + +} diff --git a/src/world/actor/skills.ts b/src/world/actor/skills.ts new file mode 100644 index 000000000..8c1816aef --- /dev/null +++ b/src/world/actor/skills.ts @@ -0,0 +1,184 @@ +import { Actor } from '@server/world/actor/actor'; +import { Player } from '@server/world/actor/player/player'; +import { dialogueAction } from '@server/world/actor/player/action/dialogue-action'; +import { startsWithVowel } from '@server/util/strings'; +import { serverConfig } from '@server/game-server'; + +export enum Skill { + ATTACK, + DEFENCE, + STRENGTH, + HITPOINTS, + RANGED, + PRAYER, + MAGIC, + COOKING, + WOODCUTTING, + FLETCHING, + FISHING, + FIREMAKING, + CRAFTING, + SMITHING, + MINING, + HERBLORE, + AGILITY, + THIEVING, + SLAYER, + FARMING, + RUNECRAFTING, + CONSTRUCTION = 22 +} + +export interface SkillDetail { + readonly name: string; + readonly advancementWidgetId?: number; +} + +export const skillDetails: SkillDetail[] = [ + { name: 'Attack', advancementWidgetId: 158 }, + { name: 'Defence', advancementWidgetId: 161 }, + { name: 'Strength', advancementWidgetId: 175 }, + { name: 'Hitpoints', advancementWidgetId: 167 }, + { name: 'Ranged', advancementWidgetId: 171 }, + { name: 'Prayer', advancementWidgetId: 170 }, + { name: 'Magic', advancementWidgetId: 168 }, + { name: 'Cooking', advancementWidgetId: 159 }, + { name: 'Woodcutting', advancementWidgetId: 177 }, + { name: 'Fletching', advancementWidgetId: 165 }, + { name: 'Fishing', advancementWidgetId: 164 }, + { name: 'Firemaking', advancementWidgetId: 163 }, + { name: 'Crafting', advancementWidgetId: 160 }, + { name: 'Smithing', advancementWidgetId: 174 }, + { name: 'Mining', advancementWidgetId: 169 }, + { name: 'Herblore', advancementWidgetId: 166 }, + { name: 'Agility', advancementWidgetId: 157 }, + { name: 'Thieving', advancementWidgetId: 176 }, + { name: 'Slayer', advancementWidgetId: 173 }, + { name: 'Farming', advancementWidgetId: 162 }, + { name: 'Runecrafting', advancementWidgetId: 172 }, + null, + { name: 'Construction' } +]; + +export interface SkillValue { + exp: number; + level: number; +} + +export class Skills { + + private _values: SkillValue[]; + + public constructor(private actor: Actor, values?: SkillValue[]) { + if(values) { + this._values = values; + } else { + this._values = this.defaultValues(); + } + } + + private defaultValues(): SkillValue[] { + const values: SkillValue[] = []; + skillDetails.forEach(s => values.push({ exp: 0, level: 1 })); + values[Skill.HITPOINTS] = { exp: 1154, level: 10 }; + return values; + } + + public hasSkillLevel(skillId: number, level: number): boolean { + return this.values[skillId].level >= level; + } + + public getLevelForExp(exp: number): number { + let points = 0; + let output = 0; + + for(let i = 1; i <= 99; i++) { + points += Math.floor(i + 300 * Math.pow(2, i / 7)); + output = Math.floor(points / 4); + if(output >= exp) { + return i; + } + } + + return 99; + } + + public addExp(skillId: number, exp: number): void { + const currentExp = this._values[skillId].exp; + const currentLevel = this.getLevelForExp(currentExp); + let finalExp = currentExp + (exp * serverConfig.expRate); + if(finalExp > 200000000) { + finalExp = 200000000; + } + + const finalLevel = this.getLevelForExp(finalExp); + + this.setExp(skillId, finalExp); + + if(this.actor instanceof Player) { + this.actor.outgoingPackets.updateSkill(skillId, finalLevel, finalExp); + } + + if(currentLevel !== finalLevel) { + this.setLevel(skillId, finalLevel); + + if(this.actor instanceof Player) { + const achievementDetails = skillDetails[skillId]; + if(!achievementDetails) { + return; + } + + this.actor.sendMessage(`Congratulations, you just advanced a ${achievementDetails.name.toLowerCase()} level.`); + this.showLevelUpDialogue(skillId, finalLevel); + } + } + } + + public showLevelUpDialogue(skillId: number, level: number): void { + if(!(this.actor instanceof Player)) { + return; + } + + const player = this.actor as Player; + const achievementDetails = skillDetails[skillId]; + const widgetId = achievementDetails.advancementWidgetId; + + if(!widgetId) { + return; + } + + const skillName = achievementDetails.name.toLowerCase(); + + player.queueWidget({ + widgetId, + type: 'CHAT', + closeOnWalk: true, + beforeOpened: () => { + 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}.` }); + }, + afterOpened: () => { + player.playGraphics({ id: 199, delay: 0, height: 125 }); + // @TODO sounds + } + }); + } + + public setExp(skillId: number, exp: number): void { + this._values[skillId].exp = exp; + } + + public setLevel(skillId: number, level: number): void { + this._values[skillId].level = level; + } + + public get values(): SkillValue[] { + return this._values; + } + + public set values(value: SkillValue[]) { + this._values = value; + } +} diff --git a/src/world/mob/update-flags.ts b/src/world/actor/update-flags.ts similarity index 88% rename from src/world/mob/update-flags.ts rename to src/world/actor/update-flags.ts index 1e09781a1..00b445c63 100644 --- a/src/world/mob/update-flags.ts +++ b/src/world/actor/update-flags.ts @@ -1,5 +1,5 @@ import { Position } from '../position'; -import { Mob } from '@server/world/mob/mob'; +import { Actor } from '@server/world/actor/actor'; /** * A specific chat message. @@ -37,7 +37,7 @@ export class UpdateFlags { private _appearanceUpdateRequired: boolean; private _chatMessages: ChatMessage[]; private _facePosition: Position; - private _faceMob: Mob; + private _faceActor: Actor; private _graphics: Graphic; private _animation: Animation; @@ -50,7 +50,7 @@ export class UpdateFlags { this._mapRegionUpdateRequired = false; this._appearanceUpdateRequired = false; this._facePosition = null; - this._faceMob = undefined; + this._faceActor = undefined; this._graphics = null; this._animation = undefined; @@ -68,8 +68,8 @@ export class UpdateFlags { } public get updateBlockRequired(): boolean { - return this._appearanceUpdateRequired || this._chatMessages !== null || this._facePosition !== null || - this._graphics !== null || this._animation !== undefined || this._faceMob !== undefined; + return this._appearanceUpdateRequired || this._chatMessages.length !== 0 || this._facePosition !== null || + this._graphics !== null || this._animation !== undefined || this._faceActor !== undefined; } public get mapRegionUpdateRequired(): boolean { @@ -104,12 +104,12 @@ export class UpdateFlags { this._facePosition = value; } - public get faceMob(): Mob { - return this._faceMob; + public get faceActor(): Actor { + return this._faceActor; } - public set faceMob(value: Mob) { - this._faceMob = value; + public set faceActor(value: Actor) { + this._faceActor = value; } public get graphics(): Graphic { diff --git a/src/world/mob/walking-queue.ts b/src/world/actor/walking-queue.ts similarity index 77% rename from src/world/mob/walking-queue.ts rename to src/world/actor/walking-queue.ts index f6df2e811..0a84eee08 100644 --- a/src/world/mob/walking-queue.ts +++ b/src/world/actor/walking-queue.ts @@ -1,20 +1,20 @@ -import { Mob } from './mob'; +import { Actor } from './actor'; import { Position } from '../position'; import { Player } from './player/player'; import { world } from '@server/game-server'; import { Chunk } from '../map/chunk'; -import { MapRegionTile } from '@runejs/cache-parser'; +import { Tile } from '@runejs/cache-parser'; import { Npc } from './npc/npc'; /** - * Controls a mobile entity's movement. + * Controls an actor's movement. */ export class WalkingQueue { private queue: Position[]; private _valid: boolean; - public constructor(private readonly mob: Mob) { + public constructor(private readonly actor: Actor) { this.queue = []; this._valid = false; } @@ -29,7 +29,7 @@ export class WalkingQueue { public getLastPosition(): Position { if(this.queue.length === 0) { - return this.mob.position; + return this.actor.position; } else { return this.queue[this.queue.length - 1]; } @@ -57,7 +57,7 @@ export class WalkingQueue { lastX = x - diffX; lastY = y - diffY; - const newPosition = new Position(lastX, lastY, this.mob.position.level); + const newPosition = new Position(lastX, lastY, this.actor.position.level); if(this.canMoveTo(lastPosition, newPosition)) { lastPosition = newPosition; @@ -70,7 +70,7 @@ export class WalkingQueue { } if(lastX !== x || lastY !== y && this.valid) { - const newPosition = new Position(x, y, this.mob.position.level); + const newPosition = new Position(x, y, this.actor.position.level); if(this.canMoveTo(lastPosition, newPosition)) { newPosition.metadata = positionMetadata; @@ -82,7 +82,7 @@ export class WalkingQueue { } public moveIfAble(xDiff: number, yDiff: number): boolean { - const position = this.mob.position; + const position = this.actor.position; const newPosition = new Position(position.x + xDiff, position.y + yDiff, position.level); if(this.canMoveTo(position, newPosition)) { @@ -99,7 +99,7 @@ export class WalkingQueue { let destinationChunk: Chunk = world.chunkManager.getChunkForWorldPosition(destination); const positionAbove: Position = new Position(destination.x, destination.y, destination.level + 1); const chunkAbove: Chunk = world.chunkManager.getChunkForWorldPosition(positionAbove); - let tile: MapRegionTile = chunkAbove.getTile(positionAbove); + let tile: Tile = chunkAbove.getTile(positionAbove); if(!tile || !tile.bridge) { tile = destinationChunk.getTile(destination); @@ -127,7 +127,7 @@ export class WalkingQueue { // West if(destination.x < initialX && destination.y == initialY) { if((destinationAdjacency[destinationLocalX][destinationLocalY] & 0x1280108) != 0) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move west.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move west.`); return false; } } @@ -135,7 +135,7 @@ export class WalkingQueue { // East if(destination.x > initialX && destination.y == initialY) { if((destinationAdjacency[destinationLocalX][destinationLocalY] & 0x1280180) != 0) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move east.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move east.`); return false; } } @@ -143,7 +143,7 @@ export class WalkingQueue { // South if(destination.y < initialY && destination.x == initialX) { if((destinationAdjacency[destinationLocalX][destinationLocalY] & 0x1280102) != 0) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move south.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move south.`); return false; } } @@ -151,7 +151,7 @@ export class WalkingQueue { // North if(destination.y > initialY && destination.x == initialX) { if((destinationAdjacency[destinationLocalX][destinationLocalY] & 0x1280120) != 0) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move north.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move north.`); return false; } } @@ -160,7 +160,7 @@ export class WalkingQueue { if(destination.x < initialX && destination.y < initialY) { if(!this.canMoveDiagonally(origin, destinationAdjacency, destinationLocalX, destinationLocalY, initialX, initialY, -1, -1, 0x128010e, 0x1280108, 0x1280102)) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move south-west.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move south-west.`); return false; } } @@ -169,7 +169,7 @@ export class WalkingQueue { if(destination.x > initialX && destination.y < initialY) { if(!this.canMoveDiagonally(origin, destinationAdjacency, destinationLocalX, destinationLocalY, initialX, initialY, 1, -1, 0x1280183, 0x1280180, 0x1280102)) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move south-east.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move south-east.`); return false; } } @@ -178,7 +178,7 @@ export class WalkingQueue { if(destination.x < initialX && destination.y > initialY) { if(!this.canMoveDiagonally(origin, destinationAdjacency, destinationLocalX, destinationLocalY, initialX, initialY, -1, 1, 0x1280138, 0x1280108, 0x1280120)) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move north-west.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move north-west.`); return false; } } @@ -187,7 +187,7 @@ export class WalkingQueue { if(destination.x > initialX && destination.y > initialY) { if(!this.canMoveDiagonally(origin, destinationAdjacency, destinationLocalX, destinationLocalY, initialX, initialY, 1, 1, 0x12801e0, 0x1280180, 0x1280120)) { - // logger.warn(`${this.mob instanceof Player ? this.mob.username + ' c' : 'C'}an not move north-east.`); + // logger.warn(`${this.actor instanceof Player ? this.actor.username + ' c' : 'C'}an not move north-east.`); return false; } } @@ -212,7 +212,7 @@ export class WalkingQueue { private calculateLocalCornerPosition(cornerX: number, cornerY: number, origin: Position): { localX: number, localY: number, chunk: Chunk } { const cornerPosition: Position = new Position(cornerX, cornerY, origin.level + 1); let cornerChunk: Chunk = world.chunkManager.getChunkForWorldPosition(cornerPosition); - const tileAbove: MapRegionTile = cornerChunk.getTile(cornerPosition); + const tileAbove: Tile = cornerChunk.getTile(cornerPosition); if(!tileAbove || !tileAbove.bridge) { cornerPosition.level = cornerPosition.level - 1; cornerChunk = world.chunkManager.getChunkForWorldPosition(cornerPosition); @@ -224,8 +224,8 @@ export class WalkingQueue { } public resetDirections(): void { - this.mob.walkDirection = -1; - this.mob.runDirection = -1; + this.actor.walkDirection = -1; + this.actor.runDirection = -1; } public calculateDirection(diffX: number, diffY: number): number { @@ -257,17 +257,17 @@ export class WalkingQueue { } public process(): void { - if(this.queue.length === 0 || !this.valid) { + if(this.actor.busy || this.queue.length === 0 || !this.valid) { this.resetDirections(); return; } const walkPosition = this.queue.shift(); - if(this.mob instanceof Player) { - this.mob.actionsCancelled.next(true); + if(this.actor instanceof Player) { + this.actor.actionsCancelled.next(true); - const activeWidget = this.mob.activeWidget; + const activeWidget = this.actor.activeWidget; if(activeWidget && (!walkPosition.metadata || !walkPosition.metadata.ignoreWidgets)) { if(activeWidget.disablePlayerMovement) { this.resetDirections(); @@ -277,18 +277,18 @@ export class WalkingQueue { activeWidget.forceClosed(); } - this.mob.activeWidget = null; + this.actor.activeWidget = null; } } } - this.mob.clearFaceMob(); + this.actor.clearFaceActor(); - const currentPosition = this.mob.position; + const currentPosition = this.actor.position; if(this.canMoveTo(currentPosition, walkPosition)) { const oldChunk = world.chunkManager.getChunkForWorldPosition(currentPosition); - const lastMapRegionUpdatePosition = this.mob.lastMapRegionUpdatePosition; + const lastMapRegionUpdatePosition = this.actor.lastMapRegionUpdatePosition; const walkDiffX = walkPosition.x - currentPosition.x; const walkDiffY = walkPosition.y - currentPosition.y; @@ -299,13 +299,13 @@ export class WalkingQueue { return; } - this.mob.position = walkPosition; + this.actor.position = walkPosition; let runDir = -1; // @TODO npc running - if(this.mob instanceof Player) { - if(this.mob.settings.runEnabled && this.queue.length !== 0) { + if(this.actor instanceof Player) { + if(this.actor.settings.runEnabled && this.queue.length !== 0) { const runPosition = this.queue.shift(); if(this.canMoveTo(walkPosition, runPosition)) { @@ -314,7 +314,7 @@ export class WalkingQueue { runDir = this.calculateDirection(runDiffX, runDiffY); if(runDir != -1) { - this.mob.position = runPosition; + this.actor.position = runPosition; } } else { this.resetDirections(); @@ -323,32 +323,32 @@ export class WalkingQueue { } } - this.mob.walkDirection = walkDir; - this.mob.runDirection = runDir; + this.actor.walkDirection = walkDir; + this.actor.runDirection = runDir; if(runDir !== -1) { - this.mob.faceDirection = runDir; + this.actor.faceDirection = runDir; } else { - this.mob.faceDirection = walkDir; + this.actor.faceDirection = walkDir; } - const newChunk = world.chunkManager.getChunkForWorldPosition(this.mob.position); + const newChunk = world.chunkManager.getChunkForWorldPosition(this.actor.position); - if(this.mob instanceof Player) { - const mapDiffX = this.mob.position.x - (lastMapRegionUpdatePosition.chunkX * 8); - const mapDiffY = this.mob.position.y - (lastMapRegionUpdatePosition.chunkY * 8); + if(this.actor instanceof Player) { + const mapDiffX = this.actor.position.x - (lastMapRegionUpdatePosition.chunkX * 8); + const mapDiffY = this.actor.position.y - (lastMapRegionUpdatePosition.chunkY * 8); if(mapDiffX < 16 || mapDiffX > 87 || mapDiffY < 16 || mapDiffY > 87) { - this.mob.updateFlags.mapRegionUpdateRequired = true; - this.mob.lastMapRegionUpdatePosition = this.mob.position; + this.actor.updateFlags.mapRegionUpdateRequired = true; + this.actor.lastMapRegionUpdatePosition = this.actor.position; } } if(!oldChunk.equals(newChunk)) { - if(this.mob instanceof Player) { - this.mob.metadata['updateChunk'] = { newChunk, oldChunk }; - } else if(this.mob instanceof Npc) { - oldChunk.removeNpc(this.mob); - newChunk.addNpc(this.mob); + if(this.actor instanceof Player) { + this.actor.metadata['updateChunk'] = { newChunk, oldChunk }; + } else if(this.actor instanceof Npc) { + oldChunk.removeNpc(this.actor); + newChunk.addNpc(this.actor); } } } else { diff --git a/src/world/config/animation-ids.ts b/src/world/config/animation-ids.ts new file mode 100644 index 000000000..a033061f0 --- /dev/null +++ b/src/world/config/animation-ids.ts @@ -0,0 +1,14 @@ +export const animationIds = { + milkCow: 2305, + lightingFire: 733, + homeTeleportDraw: 4847, + homeTeleportSit: 4850, + homeTeleportPullOutAndReadBook: 4853, + homeTeleportReadBookAndGlowCircle: 4855, + homeTeleport: 4857, + fillContainerWithWater: 832, + shearSheep: 893, + spinSpinningWheel: 894, + cry: 860, + climbLadder: 828 +}; diff --git a/src/world/config/gfx-ids.ts b/src/world/config/gfx-ids.ts new file mode 100644 index 000000000..c3d8354d4 --- /dev/null +++ b/src/world/config/gfx-ids.ts @@ -0,0 +1,6 @@ +export const gfxIds = { + homeTeleportDraw: 800, + homeTeleportPullOutBook: 802, + homeTeleportCircleGlow: 803, + homeTeleport: 804, +}; diff --git a/src/world/config/item-data.ts b/src/world/config/item-data.ts index cf7a080f0..259e965ee 100644 --- a/src/world/config/item-data.ts +++ b/src/world/config/item-data.ts @@ -123,11 +123,11 @@ export function parseItemData(itemDefinitions: Map): Map const itemDataList = safeLoad(readFileSync('data/config/item-data.yaml', 'utf8'), { schema: JSON_SCHEMA }) as ItemData[]; if(!itemDataList || itemDataList.length === 0) { - throw 'Unable to read item data.'; + throw new Error('Unable to read item data.'); } const itemDetailsMap: Map = new Map(); - itemDefinitions.forEach(itemDefinition => { + itemDefinitions.forEach((itemDefinition: ItemDefinition) => { let itemData = itemDataList.find(i => i.id === itemDefinition.id); if(!itemData) { diff --git a/src/world/config/item-ids.ts b/src/world/config/item-ids.ts new file mode 100644 index 000000000..0e4c68eb3 --- /dev/null +++ b/src/world/config/item-ids.ts @@ -0,0 +1,36 @@ +export const itemIds = { + coins: 995, + bucket: 1925, + bucketOfMilk: 1927, + bucketOfWater: 1929, + ashes: 592, + tinderbox: 590, + logs: 1511, + jug: 1935, + jugOfWater: 1937, + pot: 1931, + potOfFlour: 1933, + egg: 1944, + grain: 1947, + wool: 1737, + ballOfWool: 1759, + cabbage: 1965, + flax: 1779, + bowstring: 1777, + oakRoots: 6043, + willowRoots: 6045, + mapleRoots: 6047, + yewRoots: 6049, + magicRoots: 6051, + potato: 1942, + onion: 1957, + shears: 1735, + magicString: 6038, + crossbowString: 9438, + sinew: 9436, + recruitmentDrive: { + shears: 5603, + }, + + +}; diff --git a/src/world/config/npc-ids.ts b/src/world/config/npc-ids.ts new file mode 100644 index 000000000..62ba613c8 --- /dev/null +++ b/src/world/config/npc-ids.ts @@ -0,0 +1,17 @@ +export const npcIds = { + hans: 0, + man: 1, + farmer: 7, + tramp: 11, + sheep: 43, + nakedSheep: 42, + shopKeeper: 520, + lumbridgeBob: 519, + lumbridgeCook: 278, + dommik: 545, + louieLegs: 542, + gemTrader: 540, + gillieGroats: 3807, + ranael: 544, + millieMiller: 3806 +}; diff --git a/src/world/config/npc-spawn.ts b/src/world/config/npc-spawn.ts index c7acd8cd3..ba07dda36 100644 --- a/src/world/config/npc-spawn.ts +++ b/src/world/config/npc-spawn.ts @@ -19,7 +19,7 @@ export function parseNpcSpawns(): NpcSpawn[] { const npcSpawns = safeLoad(readFileSync('data/config/npc-spawns.yaml', 'utf8'), { schema: JSON_SCHEMA }) as NpcSpawn[]; if(!npcSpawns || npcSpawns.length === 0) { - throw 'Unable to read npc spawns.'; + throw new Error('Unable to read npc spawns.'); } logger.info(`${npcSpawns.length} npc spawns found.`); diff --git a/src/world/config/object-ids.ts b/src/world/config/object-ids.ts new file mode 100644 index 000000000..3d4cc3b5e --- /dev/null +++ b/src/world/config/object-ids.ts @@ -0,0 +1,13 @@ +export const objectIds = { + milkableCow: 8689, + fire: 2732, + spinningWheel: 2644, + bankBooth: 2213, + shortCuts: { + stile: 12982 + }, + ladders: { + taverlyDungeonOverworld: 1759, + taverlyDungeonUnderground: 1755, + } +}; diff --git a/src/world/config/quests.ts b/src/world/config/quests.ts new file mode 100644 index 000000000..e6d337307 --- /dev/null +++ b/src/world/config/quests.ts @@ -0,0 +1,41 @@ +import { ActionPlugin } from '@server/plugins/plugin'; + +export interface Quest { + // The unique ID string for the quest. + id: string; + // The child ID of the quest's entry within the quest tab. + questTabId: number; + // The formatted name of the quest. + name: string; + // How many quest points are awarded upon completion of the quest. + points: number; + // The stages that the quest consists of. The given string should be the contents of the quest journal when opened for + // that specific quest stage. A string or a function returning a string can be provided. + stages: { [key: string]: Function | string | { color: number, text: string } }; + // Data for what to show on the "Quest Complete" widget. + completion: { + rewards: string[]; + onComplete: Function; + modelId?: number; + itemId?: number; + modelRotationX?: number; + modelRotationY?: number; + modelZoom?: number; + }; +} + +export interface QuestPlugin extends ActionPlugin { + // The quest being registered. + quest: Quest; +} + +// @TODO quest requirements +export let quests: { [key: string]: Quest }; + +export function setQuestPlugins(questPlugins: ActionPlugin[]): void { + quests = {}; + + for(const plugin of questPlugins as QuestPlugin[]) { + quests[plugin.quest.id] = plugin.quest; + } +} diff --git a/src/world/config/server-config.ts b/src/world/config/server-config.ts index 62ab2b13f..ef393f408 100644 --- a/src/world/config/server-config.ts +++ b/src/world/config/server-config.ts @@ -8,6 +8,7 @@ export interface ServerConfig { host: string; port: number; showWelcome: boolean; + expRate: number; } export function parseServerConfig(useDefault?: boolean): ServerConfig { @@ -19,7 +20,7 @@ export function parseServerConfig(useDefault?: boolean): ServerConfig { logger.warn('Server config not provided, using default...'); return parseServerConfig(true); } else { - throw 'Syntax Error'; + throw new Error('Syntax Error'); } } diff --git a/src/world/config/shops.ts b/src/world/config/shops.ts index 095eda509..d6509e179 100644 --- a/src/world/config/shops.ts +++ b/src/world/config/shops.ts @@ -1,6 +1,7 @@ import { logger } from '@runejs/logger/dist/logger'; import { JSON_SCHEMA, safeLoad } from 'js-yaml'; import { readFileSync } from 'fs'; +import { ItemContainer } from '@server/world/items/item-container'; export interface Shop { identification: string; @@ -16,6 +17,12 @@ interface ShopItems { price: number; } +export function shopItemContainer(shop: Shop): ItemContainer { + const shopContainer = new ItemContainer(40); + shop.items.forEach((item, i) => shopContainer.set(i, !item ? null : { itemId: item.id, amount: item.amountInStock }, false)); + return shopContainer; +} + export function parseShops(): Shop[] { try { logger.info('Parsing shops...'); @@ -23,7 +30,7 @@ export function parseShops(): Shop[] { const shops = safeLoad(readFileSync('data/config/shops.yaml', 'utf8'), { schema: JSON_SCHEMA }) as Shop[]; if(!shops || shops.length === 0) { - throw 'Unable to read shops.'; + throw new Error('Unable to read shops.'); } logger.info(`${shops.length} shops found.`); @@ -33,4 +40,4 @@ export function parseShops(): Shop[] { logger.error('Error parsing shops: ' + error); return null; } -} \ No newline at end of file +} diff --git a/src/world/config/songs.ts b/src/world/config/songs.ts new file mode 100644 index 000000000..561500a4f --- /dev/null +++ b/src/world/config/songs.ts @@ -0,0 +1,3 @@ +export const songs = { + harmony: 76 +}; diff --git a/src/world/config/sound-ids.ts b/src/world/config/sound-ids.ts new file mode 100644 index 000000000..d2cdeea98 --- /dev/null +++ b/src/world/config/sound-ids.ts @@ -0,0 +1,21 @@ +export const soundIds = { + dropItem: 2739, + pickupItem: 2582, + milkCow: 372, + lightingFire: 2599, + fireLit: 2594, + openDoor: 62, + closeDoor: 60, + openGate: 67, + closeGate: 66, + homeTeleportDraw: 193, + homeTeleportSit: 196, + homeTeleportPullOutBook: 194, + homeTeleportCircleGlowAndTeleport: 195, + emptyBucket: 2401, + potContentModified: 2584, + fillContainerWithWater: 2609, + sheepBaa: 2053, + shearSheep: 761, + spinWool: 2590, +}; diff --git a/src/world/config/widget.ts b/src/world/config/widget.ts new file mode 100644 index 000000000..b55ebe37c --- /dev/null +++ b/src/world/config/widget.ts @@ -0,0 +1,86 @@ +export const widgets: any = { + characterDesign: 269, + inventory: { + widgetId: 149, + containerId: 0 + }, + equipment: { + widgetId: 387, + containerId: 25 + }, + equipmentStats: { + widgetId: 465, + containerId: 103 + }, + equipmentStatsInventory: { + widgetId: 336, + containerId: 0 + }, + bank: { + screenWidget: 12, + tabWidget: { + widgetId: 266, + containerId: 0 + } + }, + skillGuide: 308, + skillsTab: 320, + logoutTab: 182, + settingsTab: 261, + emotesTab: 464, + musicPlayerTab: 239, + questTab: 274, + shop: { + widgetId: 300, + containerId: 75, + title: 76 + }, + shopPlayerInventory: { + widgetId: 301, + containerId: 0 + }, + questJournal: 275, + questReward: 277, + welcomeScreen: 378, + welcomeScreenChildren: { + cogs: 16, + question: 17, + drama: 18, + bankPin: 19, + bankPinQuestion: 20, + scamming: 21, + bankPinKey: 22, + christmas: 23, + killcount: 24 + }, + whatWouldYouLikeToSpin: 459 +}; + +export const widgetScripts = { + musicPlayer: 18, + attackStyle: 43, + brightness: 166, + unknown: 167, // ???? + musicVolume: 168, + soundEffectVolume: 169, + mouseButtons: 170, + chatEffects: 171, + autoRetaliate: 172, + runMode: 173, + splitPrivateChat: 287, + bankInsertMode: 304, + acceptAid: 427, + areaEffectVolume: 872, + questPoints: 101 +}; + +export interface PlayerWidget { + widgetId: number; + secondaryWidgetId?: number; + type: 'SCREEN' | 'CHAT' | 'FULLSCREEN' | 'SCREEN_AND_TAB'; + disablePlayerMovement?: boolean; + closeOnWalk?: boolean; + forceClosed?: Function; + beforeOpened?: Function; + afterOpened?: Function; +} diff --git a/src/world/direction.ts b/src/world/direction.ts index 731da8fd0..c62f4d77c 100644 --- a/src/world/direction.ts +++ b/src/world/direction.ts @@ -1,4 +1,4 @@ -interface DirectionData { +export interface DirectionData { index: number; deltaX: number; deltaY: number; @@ -60,3 +60,18 @@ export const directionData: { [key: string]: DirectionData } = { } }; export const WNES: Direction[] = ['WEST', 'NORTH', 'EAST', 'SOUTH']; + +export const directionFromIndex = (index: number): DirectionData => { + const keys = Object.keys(directionData); + for (const key of keys) { + if (directionData[key].index === index) { + return directionData[key]; + } + } + + return null; +}; + +export const oppositeDirectionIndex = (index: number): number => { + return 7 - index; +}; diff --git a/src/world/items/item-container.ts b/src/world/items/item-container.ts index 3e45d61ac..64409640e 100644 --- a/src/world/items/item-container.ts +++ b/src/world/items/item-container.ts @@ -28,6 +28,41 @@ export class ItemContainer { return this.findIndex(item) !== -1; } + /** + * Finds all slots within the container that contain the specified items. + * @param search The item id or Item object to search for. + * @returns An array of slot numbers. + */ + public findAll(search: number | Item): number[] { + if(typeof search !== 'number') { + search = search.itemId; + } + + const stackable = world.itemData.get(search).stackable; + + if(stackable) { + const index = this.findIndex(search); + + if(index === null || index === -1) { + return []; + } else { + return [ index ]; + } + } else { + const slots = []; + + for(let i = 0; i < this.size; i++) { + const item = this.items[i]; + + if(item && item.itemId === search) { + slots.push(i); + } + } + + return slots; + } + } + public findIndex(item: number | Item): number { const itemId = (typeof item === 'number') ? item : item.itemId; return this._items.findIndex(i => i !== null && i.itemId === itemId); diff --git a/src/world/items/item.ts b/src/world/items/item.ts index f0665f216..5e52ac301 100644 --- a/src/world/items/item.ts +++ b/src/world/items/item.ts @@ -1,4 +1,54 @@ +import { cache } from '@server/game-server'; + export interface Item { itemId: number; amount: number; } + +function itemInventoryOptions(itemId: number): string[] { + const itemDefinition = cache.itemDefinitions.get(itemId); + if(!itemDefinition) { + return []; + } + + return itemDefinition.inventoryOptions; +} + +export const getItemOptions = (itemId: number, widget: { widgetId: number, containerId: number }): string[] => { + const widgetDefinition = cache.widgets.get(widget.widgetId); + if(!widgetDefinition || !widgetDefinition.children || widgetDefinition.children.length <= widget.containerId) { + return itemInventoryOptions(itemId); + } + + const widgetChild = widgetDefinition.children[widget.containerId]; + if(!widgetChild || !widgetChild.items || !widgetChild.options) { + return itemInventoryOptions(itemId); + } + + let hasWidgetOptions = false; + for(const option of widgetChild.options) { + if(option) { + hasWidgetOptions = true; + } + } + + if(!hasWidgetOptions) { + return itemInventoryOptions(itemId); + } + + return widgetChild.options; +}; + +export const getItemOption = (itemId: number, optionNumber: number, widget: { widgetId: number, containerId: number }): string => { + const optionIndex = optionNumber - 1; + const options = getItemOptions(itemId, widget); + let option = 'option-' + optionNumber; + + if(options && options.length >= optionNumber) { + if(options[optionIndex] !== null && options[optionIndex].toLowerCase() !== 'hidden') { + option = options[optionIndex].toLowerCase(); + } + } + + return option.replace(/ /g, '-'); +}; diff --git a/src/world/items/world-item.ts b/src/world/items/world-item.ts index 432205155..11a1dbbe4 100644 --- a/src/world/items/world-item.ts +++ b/src/world/items/world-item.ts @@ -1,5 +1,5 @@ import { Position } from '@server/world/position'; -import { Player } from '@server/world/mob/player/player'; +import { Player } from '@server/world/actor/player/player'; export interface WorldItem { itemId: number; diff --git a/src/world/map/chunk-manager.ts b/src/world/map/chunk-manager.ts index cc1fdcae8..c9b22e457 100644 --- a/src/world/map/chunk-manager.ts +++ b/src/world/map/chunk-manager.ts @@ -1,12 +1,7 @@ import { Chunk } from './chunk'; import { Position } from '../position'; -import { gameCache } from '../../game-server'; import { logger } from '@runejs/logger'; -import { LandscapeObject } from '@runejs/cache-parser'; -import { Item } from '@server/world/items/item'; -import { Player } from '@server/world/mob/player/player'; -import { WorldItem } from '@server/world/items/world-item'; -import { World } from '@server/world/world'; +import { cache } from '@server/game-server'; /** * Controls all of the game world's map chunks. @@ -19,164 +14,10 @@ export class ChunkManager { this.chunkMap = new Map(); } - public removeWorldItem(worldItem: WorldItem): void { - const chunk = this.getChunkForWorldPosition(worldItem.position); - chunk.removeWorldItem(worldItem); - worldItem.removed = true; - this.deleteWorldItemForPlayers(worldItem, chunk); - } - - public spawnWorldItem(item: Item, position: Position, initiallyVisibleTo?: Player, expires?: number): WorldItem { - const chunk = this.getChunkForWorldPosition(position); - const worldItem: WorldItem = { - itemId: item.itemId, - amount: item.amount, - position, - initiallyVisibleTo, - expires - }; - - chunk.addWorldItem(worldItem); - - if(initiallyVisibleTo) { - initiallyVisibleTo.packetSender.setWorldItem(worldItem, worldItem.position); - setTimeout(() => { - if(worldItem.removed) { - return; - } - - this.spawnWorldItemForPlayers(worldItem, chunk, initiallyVisibleTo); - worldItem.initiallyVisibleTo = undefined; - }, 100 * World.TICK_LENGTH); - } else { - this.spawnWorldItemForPlayers(worldItem, chunk); - } - - if(expires) { - setTimeout(() => { - if(worldItem.removed) { - return; - } - - this.removeWorldItem(worldItem); - }, expires * World.TICK_LENGTH); - } - - return worldItem; - } - - private spawnWorldItemForPlayers(worldItem: WorldItem, chunk: Chunk, excludePlayer?: Player): Promise { - return new Promise(resolve => { - const nearbyPlayers = this.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); - - nearbyPlayers.forEach(player => { - if(excludePlayer && excludePlayer.equals(player)) { - return; - } - - player.packetSender.setWorldItem(worldItem, worldItem.position); - }); - - resolve(); - }); - } - - private deleteWorldItemForPlayers(worldItem: WorldItem, chunk: Chunk): Promise { - return new Promise(resolve => { - const nearbyPlayers = this.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); - - nearbyPlayers.forEach(player => { - player.packetSender.removeWorldItem(worldItem, worldItem.position); - }); - - resolve(); - }); - } - - public toggleObjects(newObject: LandscapeObject, oldObject: LandscapeObject, newPosition: Position, oldPosition: Position, - newChunk: Chunk, oldChunk: Chunk, newObjectInCache: boolean): void { - if(newObjectInCache) { - this.deleteRemovedObjectMarker(newObject, newPosition, newChunk); - this.deleteAddedObjectMarker(oldObject, oldPosition, oldChunk); - } - - this.addLandscapeObject(newObject, newPosition); - this.removeLandscapeObject(oldObject, oldPosition); - } - - public deleteAddedObjectMarker(object: LandscapeObject, position: Position, chunk: Chunk): void { - chunk.addedLandscapeObjects.delete(`${position.x},${position.y},${object.objectId}`); - } - - public deleteRemovedObjectMarker(object: LandscapeObject, position: Position, chunk: Chunk): void { - chunk.removedLandscapeObjects.delete(`${position.x},${position.y},${object.objectId}`); - } - - public addTemporaryLandscapeObject(object: LandscapeObject, position: Position, expireTicks: number): Promise { - return new Promise(resolve => { - this.addLandscapeObject(object, position); - - setTimeout(() => { - this.removeLandscapeObject(object, position, false) - .then(chunk => this.deleteAddedObjectMarker(object, position, chunk)); - resolve(); - }, expireTicks * World.TICK_LENGTH); - }); - } - - public removeLandscapeObjectTemporarily(object: LandscapeObject, position: Position, expireTicks: number): Promise { - const chunk = this.getChunkForWorldPosition(position); - chunk.removeObject(object, position); - - return new Promise(resolve => { - const nearbyPlayers = this.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); - - nearbyPlayers.forEach(player => { - player.packetSender.removeLandscapeObject(object, position); - }); - - setTimeout(() => { - this.deleteRemovedObjectMarker(object, position, chunk); - this.addLandscapeObject(object, position); - resolve(); - }, expireTicks * World.TICK_LENGTH); - }); - } - - public removeLandscapeObject(object: LandscapeObject, position: Position, markRemoved: boolean = true): Promise { - const chunk = this.getChunkForWorldPosition(position); - chunk.removeObject(object, position, markRemoved); - - return new Promise(resolve => { - const nearbyPlayers = this.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); - - nearbyPlayers.forEach(player => { - player.packetSender.removeLandscapeObject(object, position); - }); - - resolve(chunk); - }); - } - - public addLandscapeObject(object: LandscapeObject, position: Position): Promise { - const chunk = this.getChunkForWorldPosition(position); - chunk.addObject(object, position); - - return new Promise(resolve => { - const nearbyPlayers = this.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); - - nearbyPlayers.forEach(player => { - player.packetSender.setLandscapeObject(object, position); - }); - - resolve(); - }); - } - public generateCollisionMaps(): void { logger.info('Generating game world collision maps...'); - const tileList = gameCache.mapRegions.mapRegionTileList; + const tileList = cache.mapData.tiles; for(const tile of tileList) { const position = new Position(tile.x, tile.y, tile.level); @@ -184,12 +25,12 @@ export class ChunkManager { chunk.addTile(tile, position); } - const objectList = gameCache.mapRegions.landscapeObjectList; + const objectList = cache.mapData.locationObjects; - for(const landscapeObject of objectList) { - const position = new Position(landscapeObject.x, landscapeObject.y, landscapeObject.level); + for(const locationObject of objectList) { + const position = new Position(locationObject.x, locationObject.y, locationObject.level); const chunk = this.getChunkForWorldPosition(position); - chunk.setCacheLandscapeObject(landscapeObject, position); + chunk.setCacheLocationObject(locationObject, position); } logger.info('Game world collision maps generated.', true); diff --git a/src/world/map/chunk.ts b/src/world/map/chunk.ts index edb074340..27a2f529b 100644 --- a/src/world/map/chunk.ts +++ b/src/world/map/chunk.ts @@ -1,15 +1,15 @@ import { Position } from '../position'; -import { Player } from '../mob/player/player'; +import { Player } from '../actor/player/player'; import { CollisionMap } from './collision-map'; -import { gameCache } from '../../game-server'; -import { LandscapeObject, LandscapeObjectDefinition, MapRegionTile } from '@runejs/cache-parser'; -import { Npc } from '../mob/npc/npc'; +import { cache } from '../../game-server'; +import { LocationObject, LocationObjectDefinition, Tile } from '@runejs/cache-parser'; +import { Npc } from '../actor/npc/npc'; import { WorldItem } from '@server/world/items/world-item'; export interface ChunkUpdateItem { - object?: LandscapeObject, - worldItem?: WorldItem, - type: 'ADD' | 'REMOVE' + object?: LocationObject; + worldItem?: WorldItem; + type: 'ADD' | 'REMOVE'; } /** @@ -21,10 +21,10 @@ export class Chunk { private readonly _players: Player[]; private readonly _npcs: Npc[]; private readonly _collisionMap: CollisionMap; - private readonly _tileList: MapRegionTile[]; - private readonly _cacheLandscapeObjects: Map; - private readonly _addedLandscapeObjects: Map; - private readonly _removedLandscapeObjects: Map; + private readonly _tileList: Tile[]; + private readonly _cacheLocationObjects: Map; + private readonly _addedLocationObjects: Map; + private readonly _removedLocationObjects: Map; private readonly _worldItems: Map; public constructor(position: Position) { @@ -33,12 +33,29 @@ export class Chunk { this._npcs = []; this._collisionMap = new CollisionMap(8, 8, (position.x + 6) * 8, (position.y + 6) * 8, this); this._tileList = []; - this._cacheLandscapeObjects = new Map(); - this._addedLandscapeObjects = new Map(); - this._removedLandscapeObjects = new Map(); + this._cacheLocationObjects = new Map(); + this._addedLocationObjects = new Map(); + this._removedLocationObjects = new Map(); this._worldItems = new Map(); } + public getWorldItem(itemId: number, position: Position): WorldItem { + const key = position.key; + + if(this._worldItems.has(key)) { + const list = this._worldItems.get(key); + const worldItem = list.find(item => item.itemId === itemId); + + if(!worldItem) { + return null; + } + + return worldItem; + } + + return null; + } + public addWorldItem(worldItem: WorldItem): void { const key = worldItem.position.key; @@ -55,25 +72,26 @@ export class Chunk { const key = worldItem.position.key; if(this._worldItems.has(key)) { - let list = this._worldItems.get(key); + const list = this._worldItems.get(key); list.splice(list.indexOf(worldItem), 1); this._worldItems.set(key, list); } } - public setCacheLandscapeObject(landscapeObject: LandscapeObject, objectPosition: Position): void { + public setCacheLocationObject(locationObject: LocationObject, objectPosition: Position): void { let tile = this.getTile(objectPosition); if(!tile) { - tile = new MapRegionTile(objectPosition.x, objectPosition.y, objectPosition.level, 0); + tile = new Tile(objectPosition.x, objectPosition.y, objectPosition.level); + tile.flags = 0; this.addTile(tile, objectPosition); } - this.markOnCollisionMap(landscapeObject, objectPosition, true); - this._cacheLandscapeObjects.set(`${objectPosition.x},${objectPosition.y},${landscapeObject.objectId}`, landscapeObject); + this.markOnCollisionMap(locationObject, objectPosition, true); + this._cacheLocationObjects.set(`${objectPosition.x},${objectPosition.y},${locationObject.objectId}`, locationObject); } - public addTile(tile: MapRegionTile, tilePosition: Position): void { + public addTile(tile: Tile, tilePosition: Position): void { const existingTile = this.getTile(tilePosition); if(existingTile) { return; @@ -82,7 +100,7 @@ export class Chunk { this._tileList.push(tile); } - public getTile(position: Position): MapRegionTile { + public getTile(position: Position): Tile { for(const tile of this._tileList) { if(position.equalsIgnoreLevel({ x: tile.x, y: tile.y })) { return tile; @@ -118,12 +136,12 @@ export class Chunk { } } - public markOnCollisionMap(landscapeObject: LandscapeObject, position: Position, mark: boolean): void { + public markOnCollisionMap(locationObject: LocationObject, position: Position, mark: boolean): void { const x: number = position.x; const y: number = position.y; - const objectType = landscapeObject.type; - const objectRotation = landscapeObject.rotation; - const objectDetails: LandscapeObjectDefinition = gameCache.landscapeObjectDefinitions.get(landscapeObject.objectId); + const objectType = locationObject.type; + const objectOrientation = locationObject.orientation; + const objectDetails: LocationObjectDefinition = cache.locationObjectDefinitions.get(locationObject.objectId); if(objectDetails.solid) { if(objectType === 22) { @@ -131,46 +149,46 @@ export class Chunk { this.collisionMap.markBlocked(x, y, mark); } } else if(objectType >= 9) { - this.collisionMap.markSolidOccupant(x, y, objectDetails.sizeX, objectDetails.sizeY, objectRotation, objectDetails.walkable, mark); + this.collisionMap.markSolidOccupant(x, y, objectDetails.sizeX, objectDetails.sizeY, objectOrientation, objectDetails.nonWalkable, mark); } else if(objectType >= 0 && objectType <= 3) { if(mark) { - this.collisionMap.markWall(x, y, objectType, objectRotation, objectDetails.walkable); + this.collisionMap.markWall(x, y, objectType, objectOrientation, objectDetails.nonWalkable); } else { - this.collisionMap.unmarkWall(x, y, objectType, objectRotation, objectDetails.walkable); + this.collisionMap.unmarkWall(x, y, objectType, objectOrientation, objectDetails.nonWalkable); } } } } - public removeObject(object: LandscapeObject, position: Position, markRemoved: boolean = true): void { + public removeObject(object: LocationObject, position: Position, markRemoved: boolean = true): void { if(markRemoved && this.getCacheObject(object.objectId, position)) { // Only add this as an "removed" object if it's from the cache, as that's all we care about - this.removedLandscapeObjects.set(`${position.x},${position.y},${object.objectId}`, object); + this.removedLocationObjects.set(`${position.x},${position.y},${object.objectId}`, object); } this.markOnCollisionMap(object, position, false); } - public addObject(object: LandscapeObject, position: Position): void { + public addObject(object: LocationObject, position: Position): void { if(!this.getCacheObject(object.objectId, position)) { // Only add this as an "added" object if there's not a cache object with the same id and position // This becomes a "custom" added object - this.addedLandscapeObjects.set(`${position.x},${position.y},${object.objectId}`, object); + this.addedLocationObjects.set(`${position.x},${position.y},${object.objectId}`, object); } this.markOnCollisionMap(object, position, true); } - public getCacheObject(objectId: number, position: Position): LandscapeObject { - return this.cacheLandscapeObjects.get(`${position.x},${position.y},${objectId}`); + public getCacheObject(objectId: number, position: Position): LocationObject { + return this.cacheLocationObjects.get(`${position.x},${position.y},${objectId}`); } - public getAddedObject(objectId: number, position: Position): LandscapeObject { - return this.addedLandscapeObjects.get(`${position.x},${position.y},${objectId}`); + public getAddedObject(objectId: number, position: Position): LocationObject { + return this.addedLocationObjects.get(`${position.x},${position.y},${objectId}`); } - public getRemovedObject(objectId: number, position: Position): LandscapeObject { - return this.removedLandscapeObjects.get(`${position.x},${position.y},${objectId}`); + public getRemovedObject(objectId: number, position: Position): LocationObject { + return this.removedLocationObjects.get(`${position.x},${position.y},${objectId}`); } public equals(chunk: Chunk): boolean { @@ -193,20 +211,20 @@ export class Chunk { return this._collisionMap; } - public get tileList(): MapRegionTile[] { + public get tileList(): Tile[] { return this._tileList; } - public get cacheLandscapeObjects(): Map { - return this._cacheLandscapeObjects; + public get cacheLocationObjects(): Map { + return this._cacheLocationObjects; } - public get addedLandscapeObjects(): Map { - return this._addedLandscapeObjects; + public get addedLocationObjects(): Map { + return this._addedLocationObjects; } - public get removedLandscapeObjects(): Map { - return this._removedLandscapeObjects; + public get removedLocationObjects(): Map { + return this._removedLocationObjects; } public get worldItems(): Map { diff --git a/src/world/map/landscape-object.ts b/src/world/map/landscape-object.ts deleted file mode 100644 index 4b39339b0..000000000 --- a/src/world/map/landscape-object.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { LandscapeObject } from '@runejs/cache-parser'; - -export interface ModifiedLandscapeObject extends LandscapeObject { - metadata?: { [key: string]: any }; -} - -export const objectKey = (object: LandscapeObject, level: boolean = false): string => { - return `${object.x},${object.y}${level ? `,${object.level}` : ''},${object.objectId}`; -}; diff --git a/src/world/map/location-object.ts b/src/world/map/location-object.ts new file mode 100644 index 000000000..f63e5ccc8 --- /dev/null +++ b/src/world/map/location-object.ts @@ -0,0 +1,9 @@ +import { LocationObject } from '@runejs/cache-parser'; + +export interface ModifiedLocationObject extends LocationObject { + metadata?: { [key: string]: any }; +} + +export const objectKey = (object: LocationObject, level: boolean = false): string => { + return `${object.x},${object.y}${level ? `,${object.level}` : ''},${object.objectId}`; +}; diff --git a/src/world/mob/player/action/action.ts b/src/world/mob/player/action/action.ts deleted file mode 100644 index 60b7789ff..000000000 --- a/src/world/mob/player/action/action.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Player } from '@server/world/mob/player/player'; -import { Position } from '@server/world/position'; -import { Subject, timer } from 'rxjs'; -import { World } from '@server/world/world'; -import { LandscapeObject } from '@runejs/cache-parser'; - -export interface InteractingAction { - interactingObject?: LandscapeObject; -} - -export const loopingAction = (player: Player, ticks?: number, delayTicks?: number) => { - const event: Subject = new Subject(); - - const subscription = timer(delayTicks === undefined ? 0 : (delayTicks * World.TICK_LENGTH), - ticks === undefined ? World.TICK_LENGTH : (ticks * World.TICK_LENGTH)).subscribe(() => { - event.next(); - }); - - const actionCancelled = player.actionsCancelled.subscribe(() => { - subscription.unsubscribe(); - actionCancelled.unsubscribe(); - }); - - return { event, cancel: () => { - subscription.unsubscribe(); - actionCancelled.unsubscribe(); - } }; -}; - -export const walkToAction = (player: Player, position: Position, interactingAction?: InteractingAction): Promise => { - return new Promise((resolve, reject) => { - player.walkingTo = position; - - const inter = setInterval(() => { - if(!player.walkingTo || !player.walkingTo.equals(position)) { - reject(); - clearInterval(inter); - return; - } - - if(!player.walkingQueue.moving()) { - if(!interactingAction) { - if(player.position.distanceBetween(position) > 1) { - reject(); - } else { - resolve(); - } - } else { - if(interactingAction.interactingObject) { - const landscapeObject = interactingAction.interactingObject; - if(player.position.withinInteractionDistance(landscapeObject)) { - resolve(); - } else { - reject(); - } - } - } - - clearInterval(inter); - player.walkingTo = null; - } - }, 100); - }); -}; diff --git a/src/world/mob/player/action/button-action.ts b/src/world/mob/player/action/button-action.ts deleted file mode 100644 index 143732d54..000000000 --- a/src/world/mob/player/action/button-action.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Player } from '@server/world/mob/player/player'; -import { pluginFilter } from '@server/plugins/plugin-loader'; -import { ActionPlugin } from '@server/plugins/plugin'; - -/** - * The definition for a button action function. - */ -export type buttonAction = (details: ButtonActionDetails) => void; - -/** - * Details about a button action. - */ -export interface ButtonActionDetails { - player: Player; - buttonId: number; -} - -/** - * Defines a button interaction plugin. - */ -export interface ButtonActionPlugin extends ActionPlugin { - buttonIds: number | number[]; - action: buttonAction; - cancelActions?: boolean; -} - -/** - * A directory of all button interaction plugins. - */ -let buttonInteractions: ButtonActionPlugin[] = [ -]; - -/** - * Sets the list of button interaction plugins. - * @param plugins The plugin list. - */ -export const setButtonPlugins = (plugins: ActionPlugin[]): void => { - buttonInteractions = plugins as ButtonActionPlugin[]; -}; - -export const buttonAction = (player: Player, buttonId: number): void => { - // Find all item on item action plugins that match this action - const interactionPlugins = buttonInteractions.filter(plugin => pluginFilter(plugin.buttonIds, buttonId)); - - if(interactionPlugins.length === 0) { - player.packetSender.chatboxMessage(`Unhandled button interaction: ${buttonId}`); - return; - } - - // Immediately run the plugins - interactionPlugins.forEach(plugin => { - if(plugin.cancelActions) { - player.actionsCancelled.next(); - } - - plugin.action({ player, buttonId }); - }); -}; diff --git a/src/world/mob/player/action/buy-item-action.ts b/src/world/mob/player/action/buy-item-action.ts deleted file mode 100644 index 9e37ce823..000000000 --- a/src/world/mob/player/action/buy-item-action.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Player } from '@server/world/mob/player/player'; -import { gameCache } from '@server/game-server'; -import { widgetIds } from '@server/world/mob/player/widget'; - -export const buyItemAction = (player: Player, itemId: number, amount: number, slot: number, interfaceId: number) => { - - const purchasedItem = gameCache.itemDefinitions.get(itemId); - const coinsInInventoryIndex = player.inventory.findIndex(995); - - if(coinsInInventoryIndex === -1) { - // @TODO not enough money message - return; - } - - const amountInStack = player.inventory.amountInStack(coinsInInventoryIndex); - const amountLeftAfterPurchase = amountInStack - (purchasedItem.value * amount); - - if(amountLeftAfterPurchase < 0) { - // @TODO not enough money message - return; - } - - // Take the money. - player.inventory.set(player.inventory.findIndex(itemId), { itemId, amount: amount}); - player.inventory.set(coinsInInventoryIndex, {itemId: 995, amount: amountLeftAfterPurchase}); - - // Add the purchased item(s) to the inventory. - if(amount > 1) { - for (let i = 0; i < amount; i++) { - player.inventory.add(itemId); - } - } - - if(amount === 1) { - player.inventory.add(itemId); - } - - // Update the inventory items. - player.packetSender.sendUpdateAllWidgetItems(widgetIds.inventory, player.inventory); - -}; diff --git a/src/world/mob/player/action/drop-item-action.ts b/src/world/mob/player/action/drop-item-action.ts deleted file mode 100644 index 17cc93033..000000000 --- a/src/world/mob/player/action/drop-item-action.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Player } from '../player'; -import { world } from '@server/game-server'; -import { Item } from '@server/world/items/item'; -import { widgetIds } from '@server/world/mob/player/widget'; - -export const dropItemAction = (player: Player, item: Item, inventorySlot: number) => { - player.inventory.remove(inventorySlot); - // @TODO change packets to only update modified container slots - player.packetSender.sendUpdateAllWidgetItems(widgetIds.inventory, player.inventory); - player.packetSender.playSound(376, 7); - world.chunkManager.spawnWorldItem(item, player.position, player, 300); -}; diff --git a/src/world/mob/player/action/input-command-action.ts b/src/world/mob/player/action/input-command-action.ts deleted file mode 100644 index d89718411..000000000 --- a/src/world/mob/player/action/input-command-action.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { Player } from '../player'; -import { logger } from '@runejs/logger/dist/logger'; -import { gameCache, injectPlugins, world } from '@server/game-server'; -import { npcAction } from '@server/world/mob/player/action/npc-action'; -import { Skill } from '@server/world/mob/skills'; -import { Position } from '@server/world/position'; - -type commandHandler = (player: Player, args?: string[]) => void; - -const commands: { [key: string]: commandHandler } = { - - pos: (player: Player) => { - player.packetSender.chatboxMessage(`@[ ${player.position.x}, ${player.position.y}, ${player.position.level} ]`); - }, - - move: (player: Player, args: string[]) => { - if(args.length < 2 || args.length > 3) { - throw `move x y [level]`; - } - - const x: number = parseInt(args[0], 10); - const y: number = parseInt(args[1], 10); - let level: number = 0; - - if(args.length === 3) { - level = parseInt(args[2]); - } - - if(isNaN(x) || isNaN(y) || isNaN(level)) { - throw `move x y [level]`; - } - - player.teleport(new Position(x, y, level)); - }, - - give: (player: Player, args: string[]) => { - if(args.length < 1) { - throw `give itemId [amount?]`; - } - - const inventorySlot = player.inventory.getFirstOpenSlot(); - - if(inventorySlot === -1) { - player.packetSender.chatboxMessage(`You don't have enough free space to do that.`); - return; - } - - const itemId: number = parseInt(args[0]); - - if(isNaN(itemId)) { - throw `give itemId [amount?]`; - } - - let amount = 1; - if(args.length === 2) { - amount = parseInt(args[1]); - if(isNaN(amount) || amount < 1 || amount > 5000) { - amount = 1; - } - } - - const itemDefinition = gameCache.itemDefinitions.get(itemId); - if(!itemDefinition) { - player.packetSender.chatboxMessage(`Item ID ${itemId} not found!`); - return; - } - - player.packetSender.chatboxMessage(`amount = ${amount}`); - let actualAmount = 0; - if(itemDefinition.stackable) { - const item = { itemId, amount }; - player.giveItem(item); - actualAmount = amount; - } else { - for(let i = 0; i < amount; i++) { - if(player.giveItem({ itemId, amount: 1 })) { - actualAmount++; - } else { - break; - } - } - } - - player.packetSender.chatboxMessage(`Added ${actualAmount}x ${itemDefinition.name} to inventory.`); - }, - - npcaction: (player: Player) => { - npcAction(player, world.npcList[0], world.npcList[0].position, 'talk-to'); - }, - - chati: (player: Player, args: string[]) => { - if(args.length !== 1) { - throw `chati widgetId`; - } - - const widgetId: number = parseInt(args[0]); - - if(isNaN(widgetId)) { - throw `chati widgetId`; - } - - player.packetSender.showChatboxWidget(widgetId); - }, - - sound: (player, args) => { - if(args.length !== 1 && args.length !== 2) { - throw `sound soundId [volume?]`; - } - - const soundId: number = parseInt(args[0]); - - if(isNaN(soundId)) { - throw `sound soundId [volume?]`; - } - - let volume: number = 0; - - if(args.length === 2) { - volume = parseInt(args[1]); - - if(isNaN(volume)) { - throw `sound soundId volume`; - } - } - - player.packetSender.playSound(soundId, volume); - }, - - plugins: player => { - player.packetSender.chatboxMessage('Reloading plugins...'); - - injectPlugins() - .then(() => player.packetSender.chatboxMessage('Plugins reloaded.')) - .catch(() => player.packetSender.chatboxMessage('Error reloading plugins.')); - }, - - exptest: player => { - player.skills.addExp(Skill.WOODCUTTING, 420); - }, - - song: (player, args) => { - if(args.length !== 1) { - throw `song songId`; - } - - const songId: number = parseInt(args[0]); - - if(isNaN(songId)) { - throw `song songId`; - } - - player.packetSender.playSong(songId); - }, - - quicksong: (player, args) => { - if(args.length !== 1 && args.length !== 2) { - throw `quicksong songId [previousSongId?]`; - } - - const songId: number = parseInt(args[0]); - - if(isNaN(songId)) { - throw `quicksong songId [previousSongId?]`; - } - - let previousSongId: number = 76; - - if(args.length === 2) { - previousSongId = parseInt(args[1]); - - if(isNaN(previousSongId)) { - throw `quicksong songId [previousSongId?]`; - } - } - - player.packetSender.playQuickSong(songId, previousSongId); - }, - - anim: (player, args) => { - if(args.length !== 1) { - throw `anim animationId`; - } - - const animationId: number = parseInt(args[0]); - - if(isNaN(animationId)) { - throw `anim animationId`; - } - - player.playAnimation(animationId); - }, - - quadtree: player => { - // console.log(world.playerTree); - const values = world.playerTree.colliding({ - x: player.position.x - 2, - y: player.position.y - 2, - width: 5, - height: 5 - }); - console.log(values); - }, - - trackedplayers: player => { - player.packetSender.chatboxMessage(`Tracked players: ${player.trackedPlayers.length}`); - }, - - trackednpcs: player => { - player.packetSender.chatboxMessage(`Tracked Npcs: ${player.trackedNpcs.length}`); - }, - -}; - -export const inputCommandAction = (player: Player, command: string, args: string[]): void => { - if(commands.hasOwnProperty(command)) { - try { - commands[command](player, args); - } catch(invalidSyntaxError) { - player.packetSender.chatboxMessage(`Invalid command syntax, try ::${invalidSyntaxError}`); - } - } else { - logger.info(`Unhandled command ${command} with arguments ${JSON.stringify(args)}.`); - } -}; diff --git a/src/world/mob/player/action/item-on-item-action.ts b/src/world/mob/player/action/item-on-item-action.ts deleted file mode 100644 index a612b8aba..000000000 --- a/src/world/mob/player/action/item-on-item-action.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Player } from '@server/world/mob/player/player'; -import { Item } from '@server/world/items/item'; -import { ActionPlugin } from '@server/plugins/plugin'; - -/** - * The definition for an item on item action function. - */ -export type itemOnItemAction = (details: ItemOnItemActionDetails) => void; - -/** - * Details about an item on item action. - */ -export interface ItemOnItemActionDetails { - player: Player; - usedItem: Item; - usedWithItem: Item; - usedSlot: number; - usedWithSlot: number; - usedWidgetId: number; - usedWithWidgetId: number; -} - -/** - * Defines an item on item interaction plugin. - */ -export interface ItemOnItemActionPlugin extends ActionPlugin { - items: { item1: number, item2: number }[]; - action: itemOnItemAction; -} - -/** - * A directory of all item on item interaction plugins. - */ -let itemOnItemInteractions: ItemOnItemActionPlugin[] = [ -]; - -/** - * Sets the list of item on item interaction plugins. - * @param plugins The plugin list. - */ -export const setItemOnItemPlugins = (plugins: ActionPlugin[]): void => { - itemOnItemInteractions = plugins as ItemOnItemActionPlugin[]; -}; - -export const itemOnItemAction = (player: Player, - usedItem: Item, usedSlot: number, usedWidgetId: number, - usedWithItem: Item, usedWithSlot: number, usedWithWidgetId: number): void => { - // Find all item on item action plugins that match this action - const interactionPlugins = itemOnItemInteractions.filter(plugin => - plugin.items.findIndex(i => i.item1 === usedItem.itemId && i.item2 === usedWithItem.itemId) !== -1 || - plugin.items.findIndex(i => i.item2 === usedItem.itemId && i.item1 === usedWithItem.itemId) !== -1); - - if(interactionPlugins.length === 0) { - player.packetSender.chatboxMessage(`Unhandled item on item interaction: ${usedItem.itemId} on ${usedWithItem.itemId}`); - return; - } - - player.actionsCancelled.next(); - - // Immediately run the plugins - interactionPlugins.forEach(plugin => plugin.action({ player, usedItem, usedWithItem, usedSlot, usedWithSlot, - usedWidgetId: usedWidgetId, usedWithWidgetId: usedWithWidgetId })); -}; diff --git a/src/world/mob/player/action/object-action.ts b/src/world/mob/player/action/object-action.ts deleted file mode 100644 index 1db73a34b..000000000 --- a/src/world/mob/player/action/object-action.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Player } from '@server/world/mob/player/player'; -import { LandscapeObject, LandscapeObjectDefinition } from '@runejs/cache-parser'; -import { Position } from '@server/world/position'; -import { walkToAction } from '@server/world/mob/player/action/action'; -import { pluginFilter } from '@server/plugins/plugin-loader'; -import { logger } from '@runejs/logger/dist/logger'; -import { ActionPlugin } from '@server/plugins/plugin'; - -/** - * The definition for an object action function. - */ -export type objectAction = (details: ObjectActionDetails) => void; - -/** - * Details about an object being interacted with. - */ -export interface ObjectActionDetails { - player: Player; - object: LandscapeObject; - objectDefinition: LandscapeObjectDefinition; - position: Position; - cacheOriginal: boolean; - option: string; -} - -/** - * Defines an object interaction plugin. - * A list of object ids that apply to the plugin, the options for the object, the action to be performed, - * and whether or not the player must first walk to the object. - */ -export interface ObjectActionPlugin extends ActionPlugin { - objectIds: number | number[]; - options: string | string[]; - walkTo: boolean; - action: objectAction; -} - -/** - * A directory of all object interaction plugins. - */ -let objectInteractions: ObjectActionPlugin[] = []; - -/** - * Sets the list of object interaction plugins. - * @param plugins The plugin list. - */ -export const setObjectPlugins = (plugins: ActionPlugin[]): void => { - objectInteractions = plugins as ObjectActionPlugin[]; -}; - -// @TODO priority and cancelling other (lower priority) actions -export const objectAction = (player: Player, landscapeObject: LandscapeObject, landscapeObjectDefinition: LandscapeObjectDefinition, - position: Position, option: string, cacheOriginal: boolean): void => { - // Find all object action plugins that reference this landscape object - const interactionPlugins = objectInteractions.filter(plugin => pluginFilter(plugin.objectIds, landscapeObject.objectId, plugin.options, option)); - - if(interactionPlugins.length === 0) { - player.packetSender.chatboxMessage(`Unhandled object interaction: ${option} ${landscapeObjectDefinition.name} ` + - `(id-${landscapeObject.objectId}) @ ${position.x},${position.y},${position.level}`); - return; - } - - player.actionsCancelled.next(); - - // Separate out walk-to actions from immediate actions - const walkToPlugins = interactionPlugins.filter(plugin => plugin.walkTo); - const immediatePlugins = interactionPlugins.filter(plugin => !plugin.walkTo); - - // Make sure we walk to the object before running any of the walk-to plugins - if(walkToPlugins.length !== 0) { - walkToAction(player, position, { interactingObject: landscapeObject }) - .then(() => { - player.face(position); - - walkToPlugins.forEach(plugin => - plugin.action({ - player, - object: landscapeObject, - objectDefinition: landscapeObjectDefinition, - option, - position, - cacheOriginal - })) - }) - .catch(() => logger.warn(`Unable to complete walk-to action.`)); - } - - // Immediately run any non-walk-to plugins - if(immediatePlugins.length !== 0) { - immediatePlugins.forEach(plugin => - plugin.action({ - player, - object: landscapeObject, - objectDefinition: landscapeObjectDefinition, - option, - position, - cacheOriginal - })); - } -}; diff --git a/src/world/mob/player/action/shop-action.ts b/src/world/mob/player/action/shop-action.ts deleted file mode 100644 index ff2451ec3..000000000 --- a/src/world/mob/player/action/shop-action.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { world } from '@server/game-server'; -import { Player } from '@server/world/mob/player/player'; -import { logger } from '@runejs/logger/dist/logger'; -import { Shop } from '@server/world/config/shops'; - -function findShop(identification: string): Shop { - for(let i = 0; i <= world.shops.length; i++) { - if(world.shops[i].identification === identification) return world.shops[i]; - } - return undefined; -} - -export function openShop(player: Player, identification: string, closeOnWalk: boolean = true): void { - try { - const openedShop = findShop(identification); - if(openedShop === undefined) { - throw `Unable to find the shop with identification of: ${identification}`; - } - player.packetSender.updateWidgetString(3901, openedShop.name); - for(let i = 0; i < 30; i++) { - if(openedShop.items.length <= i) { - player.packetSender.sendUpdateSingleWidgetItem(3900, i, null); - } else { - player.packetSender.sendUpdateSingleWidgetItem(3900, i, { - itemId: openedShop.items[i].id, amount: openedShop.items[i].amountInStock - }); - } - } - for(let i = 0; i < openedShop.items.length; i++) { - player.packetSender.sendUpdateSingleWidgetItem(3900, i, { - itemId: openedShop.items[i].id, amount: openedShop.items[i].amountInStock - }); - } - player.activeWidget = { - widgetId: 3824, - type: 'SCREEN', - closeOnWalk: closeOnWalk - }; - } catch (error) { - logger.error(`Error opening shop ${identification}: ` + error); - } - -} \ No newline at end of file diff --git a/src/world/mob/player/action/unequip-item-action.ts b/src/world/mob/player/action/unequip-item-action.ts deleted file mode 100644 index 10683e145..000000000 --- a/src/world/mob/player/action/unequip-item-action.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Player } from '../player'; -import { Item } from '@server/world/items/item'; -import { widgetIds } from '../widget'; - -export const unequipItemAction = (player: Player, itemId: number, equipmentSlot: number) => { - const inventory = player.inventory; - const inventorySlot = inventory.getFirstOpenSlot(); - - if(inventorySlot === -1) { - player.packetSender.chatboxMessage(`You don't have enough free space to do that.`); - return; - } - - const equipment = player.equipment; - const itemInEquipmentSlot: Item = equipment.items[equipmentSlot]; - - if(itemInEquipmentSlot) { - equipment.remove(equipmentSlot); - inventory.set(inventorySlot, itemInEquipmentSlot); - - player.packetSender.sendUpdateSingleWidgetItem(widgetIds.inventory, inventorySlot, itemInEquipmentSlot); - player.packetSender.sendUpdateSingleWidgetItem(widgetIds.equipment, equipmentSlot, null); - player.updateBonuses(); - player.updateFlags.appearanceUpdateRequired = true; - } -}; diff --git a/src/world/mob/player/packet/impl/button-click-packet.ts b/src/world/mob/player/packet/impl/button-click-packet.ts deleted file mode 100644 index e23ba9271..000000000 --- a/src/world/mob/player/packet/impl/button-click-packet.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { buttonAction } from '@server/world/mob/player/action/button-action'; - -const ignoreButtons: number[] = [ - 3651 // character design accept button -]; - -export const buttonClickPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const buttonId = packet.readShortBE(); - - if(ignoreButtons.indexOf(buttonId) === -1) { - buttonAction(player, buttonId); - } -}; diff --git a/src/world/mob/player/packet/impl/buy-item-packet.ts b/src/world/mob/player/packet/impl/buy-item-packet.ts deleted file mode 100644 index bed2fde53..000000000 --- a/src/world/mob/player/packet/impl/buy-item-packet.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { incomingPacket } from '@server/world/mob/player/packet/incoming-packet'; -import { Player } from '@server/world/mob/player/player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { gameCache } from '@server/game-server'; -import { buyItemAction } from '@server/world/mob/player/action/buy-item-action'; - -export const buyItemPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - - if(packetId === 177) { - const slot = packet.readNegativeOffsetShortBE(); - const itemId = packet.readShortLE(); - const interfaceId = packet.readShortLE(); - - if(player.inventory.findItemIndex({itemId: 995, amount: gameCache.itemDefinitions.get(itemId).value}) === undefined) { - player.packetSender.chatboxMessage(`You don't have enough coins.`); - return; - } - - buyItemAction(player, itemId, 1, slot, interfaceId); - } - - if(packetId === 91) { - const itemId = packet.readShortLE(); - const slot = packet.readNegativeOffsetShortLE(); - const interfaceId = packet.readShortBE(); - - if(player.inventory.findItemIndex({itemId: 995, amount: gameCache.itemDefinitions.get(itemId).value * 5}) === undefined) { - player.packetSender.chatboxMessage(`You don't have enough coins.`); - return; - } - - buyItemAction(player, itemId, 5, slot, interfaceId); - } - - if(packetId === 231) { - const interfaceId = packet.readNegativeOffsetShortLE(); - const slot = packet.readShortLE(); - const itemId = packet.readShortBE(); - - if(player.inventory.findItemIndex({itemId: 995, amount: gameCache.itemDefinitions.get(itemId).value * 10}) === undefined) { - player.packetSender.chatboxMessage(`You don't have enough coins.`); - return; - } - - buyItemAction(player, itemId, 10, slot, interfaceId); - } - - return; -}; \ No newline at end of file diff --git a/src/world/mob/player/packet/impl/camera-turn-packet.ts b/src/world/mob/player/packet/impl/camera-turn-packet.ts deleted file mode 100644 index de8d8c532..000000000 --- a/src/world/mob/player/packet/impl/camera-turn-packet.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; - -export const cameraTurnPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - // Do nothing -}; diff --git a/src/world/mob/player/packet/impl/chat-packet.ts b/src/world/mob/player/packet/impl/chat-packet.ts deleted file mode 100644 index 110ccc647..000000000 --- a/src/world/mob/player/packet/impl/chat-packet.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; - -export const chatPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const color: number = packet.readByteInverted(); - const effects: number = packet.readPostNegativeOffsetByte(); - const data: Buffer = packet.getUnreadData(); - player.updateFlags.addChatMessage({ color, effects, data }); -}; diff --git a/src/world/mob/player/packet/impl/command-packet.ts b/src/world/mob/player/packet/impl/command-packet.ts deleted file mode 100644 index 6e9769e32..000000000 --- a/src/world/mob/player/packet/impl/command-packet.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { inputCommandAction } from '../../action/input-command-action'; - -export const commandPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const input = packet.readString(); - - if(!input || input.trim().length === 0) { - return; - } - - const args = input.trim().split(' '); - const command = args[0]; - - args.splice(0, 1); - - inputCommandAction(player, command, args); -}; diff --git a/src/world/mob/player/packet/impl/dialogue-interaction-packet.ts b/src/world/mob/player/packet/impl/dialogue-interaction-packet.ts deleted file mode 100644 index b7378db6c..000000000 --- a/src/world/mob/player/packet/impl/dialogue-interaction-packet.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; - -export const dialogueInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const actionId = packet.readUnsignedShortBE(); - player.dialogueInteractionEvent.next(actionId); -}; diff --git a/src/world/mob/player/packet/impl/drop-item-packet.ts b/src/world/mob/player/packet/impl/drop-item-packet.ts deleted file mode 100644 index d89ce2c4b..000000000 --- a/src/world/mob/player/packet/impl/drop-item-packet.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { logger } from '@runejs/logger/dist/logger'; -import { widgetIds } from '../../widget'; -import { dropItemAction } from '@server/world/mob/player/action/drop-item-action'; - -export const dropItemPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const slot = packet.readShortLE(); - const itemId = packet.readNegativeOffsetShortLE(); - const widgetId = packet.readNegativeOffsetShortLE(); - - if(widgetId !== widgetIds.inventory) { - logger.warn(`${player.username} attempted to drop item from incorrect widget id ${widgetId}.`); - return; - } - - if(slot < 0 || slot > 27) { - logger.warn(`${player.username} attempted to drop item ${itemId} in invalid slot ${slot}.`); - return; - } - - const itemInSlot = player.inventory.items[slot]; - - if(!itemInSlot) { - logger.warn(`${player.username} attempted to drop item ${itemId} in slot ${slot}, but they do not have that item.`); - return; - } - - if(itemInSlot.itemId !== itemId) { - logger.warn(`${player.username} attempted to drop item ${itemId} in slot ${slot}, but ${itemInSlot.itemId} was found there instead.`); - return; - } - - dropItemAction(player, itemInSlot, slot); -}; diff --git a/src/world/mob/player/packet/impl/interface-click-packet.ts b/src/world/mob/player/packet/impl/interface-click-packet.ts deleted file mode 100644 index 39d98dc39..000000000 --- a/src/world/mob/player/packet/impl/interface-click-packet.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; - -export const interfaceClickPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - // Do nothing -}; diff --git a/src/world/mob/player/packet/impl/item-equip-packet.ts b/src/world/mob/player/packet/impl/item-equip-packet.ts deleted file mode 100644 index 91c5e9e04..000000000 --- a/src/world/mob/player/packet/impl/item-equip-packet.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { logger } from '@runejs/logger/dist/logger'; -import { widgetIds } from '../../widget'; -import { equipItemAction } from '../../action/equip-item-action'; - -export const itemEquipPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const widgetId = packet.readShortLE(); - const itemId = packet.readShortLE(); - const slot = packet.readNegativeOffsetShortBE(); - - if(widgetId !== widgetIds.inventory) { - logger.warn(`${player.username} attempted to equip item from incorrect widget id ${widgetId}.`); - return; - } - - if(slot < 0 || slot > 27) { - logger.warn(`${player.username} attempted to equip item ${itemId} in invalid slot ${slot}.`); - return; - } - - const itemInSlot = player.inventory.items[slot]; - - if(!itemInSlot) { - logger.warn(`${player.username} attempted to equip item ${itemId} in slot ${slot}, but they do not have that item.`); - return; - } - - if(itemInSlot.itemId !== itemId) { - logger.warn(`${player.username} attempted to equip item ${itemId} in slot ${slot}, but ${itemInSlot.itemId} was found there instead.`); - return; - } - - equipItemAction(player, itemId, slot); -}; diff --git a/src/world/mob/player/packet/impl/item-on-item-packet.ts b/src/world/mob/player/packet/impl/item-on-item-packet.ts deleted file mode 100644 index 434bda417..000000000 --- a/src/world/mob/player/packet/impl/item-on-item-packet.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { widgetIds } from '@server/world/mob/player/widget'; -import { logger } from '@runejs/logger/dist/logger'; -import { itemOnItemAction } from '@server/world/mob/player/action/item-on-item-action'; - -export const itemOnItemPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const usedWithItemId = packet.readUnsignedShortBE(); - const usedItemSlot = packet.readUnsignedShortLE(); - const usedItemId = packet.readUnsignedShortLE(); - const usedWidgetId = packet.readNegativeOffsetShortLE(); - const usedWithItemSlot = packet.readNegativeOffsetShortBE(); - const usedWithWidgetId = packet.readNegativeOffsetShortBE(); - - if(usedWidgetId === widgetIds.inventory && usedWithWidgetId === widgetIds.inventory) { - if(usedItemSlot < 0 || usedItemSlot > 27 || usedWithItemSlot < 0 || usedWithItemSlot > 27) { - return; - } - - const usedItem = player.inventory.items[usedItemSlot]; - const usedWithItem = player.inventory.items[usedWithItemSlot]; - if(!usedItem || !usedWithItem) { - return; - } - - if(usedItem.itemId !== usedItemId || usedWithItem.itemId !== usedWithItemId) { - return; - } - - itemOnItemAction(player, usedItem, usedItemSlot, usedWidgetId, usedWithItem, usedWithItemSlot, usedWithWidgetId); - } else { - logger.warn(`Unhandled item on item case using widgets ${usedWidgetId} => ${usedWithWidgetId}`); - } -}; diff --git a/src/world/mob/player/packet/impl/item-option-1-packet.ts b/src/world/mob/player/packet/impl/item-option-1-packet.ts deleted file mode 100644 index 84325ab42..000000000 --- a/src/world/mob/player/packet/impl/item-option-1-packet.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { Player } from '../../player'; -import { widgetIds } from '../../widget'; -import { logger } from '@runejs/logger/dist/logger'; -import { unequipItemAction } from '../../action/unequip-item-action'; -import { ItemContainer } from '@server/world/items/item-container'; - -export const itemOption1Packet: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const itemId = packet.readNegativeOffsetShortBE(); - const widgetId = packet.readShortBE(); - const slot = packet.readShortBE(); - - let container: ItemContainer = null; - - if(widgetId === widgetIds.equipment) { - container = player.equipment; - } - - if(!container) { - logger.info(`Unhandled item option 1: ${widgetId}, ${slot}, ${itemId}`); - return; - } - - if(slot < 0 || slot > container.size - 1) { - logger.warn(`${player.username} attempted item option 1 on ${itemId} in invalid slot ${slot}.`); - return; - } - - const itemInSlot = container.items[slot]; - - if(!itemInSlot) { - logger.warn(`${player.username} attempted item option 1 on ${itemId} in slot ${slot}, but they do not have that item.`); - return; - } - - if(itemInSlot.itemId !== itemId) { - logger.warn(`${player.username} attempted item option 1 on ${itemId} in slot ${slot}, but ${itemInSlot.itemId} was found there instead.`); - return; - } - - if(widgetId === widgetIds.equipment) { - unequipItemAction(player, itemId, slot); - } -}; diff --git a/src/world/mob/player/packet/impl/item-swap-packet.ts b/src/world/mob/player/packet/impl/item-swap-packet.ts deleted file mode 100644 index f026cedb1..000000000 --- a/src/world/mob/player/packet/impl/item-swap-packet.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { swapItemAction } from '../../action/swap-item-action'; - -export const itemSwapPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const toSlot = packet.readNegativeOffsetShortLE(); - const swapType = packet.readPostNegativeOffsetByte(); - const widgetId = packet.readNegativeOffsetShortBE(); - const fromSlot = packet.readShortLE(); - - if(toSlot < 0 || fromSlot < 0) { - return; - } - - if(swapType === 0) { - // Swap - swapItemAction(player, fromSlot, toSlot, widgetId); - } else if(swapType === 1) { - // @TODO insert - } -}; diff --git a/src/world/mob/player/packet/impl/object-interaction-packet.ts b/src/world/mob/player/packet/impl/object-interaction-packet.ts deleted file mode 100644 index 51c53da7a..000000000 --- a/src/world/mob/player/packet/impl/object-interaction-packet.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { incomingPacket } from '../incoming-packet'; -import { Player } from '../../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { Position } from '@server/world/position'; -import { gameCache, world } from '@server/game-server'; -import { objectAction } from '@server/world/mob/player/action/object-action'; -import { logger } from '@runejs/logger/dist/logger'; - -interface ObjectInteraction { - objectId: number; - x: number; - y: number; -} - -const option1 = (packet: RsBuffer): ObjectInteraction => { - const x = packet.readNegativeOffsetShortBE(); - const y = packet.readUnsignedShortLE(); - const objectId = packet.readUnsignedShortLE(); - return { objectId, x, y }; -}; - -const option2 = (packet: RsBuffer): ObjectInteraction => { - const objectId = packet.readUnsignedShortBE(); - const x = packet.readUnsignedShortBE(); - const y = packet.readNegativeOffsetShortBE(); - return { objectId, x, y }; -}; - -const option3 = (packet: RsBuffer): ObjectInteraction => { - const y = packet.readNegativeOffsetShortBE(); - const objectId = packet.readUnsignedShortLE(); - const x = packet.readNegativeOffsetShortLE(); - return { objectId, x, y }; -}; - -const option4 = (packet: RsBuffer): ObjectInteraction => { - const x = packet.readUnsignedShortBE(); - const y = packet.readUnsignedShortLE(); - const objectId = packet.readUnsignedShortBE(); - return { objectId, x, y }; -}; - -const option5 = (packet: RsBuffer): ObjectInteraction => { - const objectId = packet.readUnsignedShortLE(); - const y = packet.readUnsignedShortLE(); - const x = packet.readUnsignedShortBE(); - return { objectId, x, y }; -}; - -export const objectInteractionPacket: incomingPacket = (player: Player, packetId: number, packetSize: number, packet: RsBuffer): void => { - const options = { - 181: { packetDef: option1, index: 0 }, - 241: { packetDef: option2, index: 1 }, - 50: { packetDef: option3, index: 2 }, - 136: { packetDef: option4, index: 3 }, - 55: { packetDef: option5, index: 4 }, - }; - - const { objectId, x, y } = options[packetId].packetDef(packet); - const level = player.position.level; - - const objectPosition = new Position(x, y, level); - const objectChunk = world.chunkManager.getChunkForWorldPosition(objectPosition); - let cacheOriginal: boolean = true; - - let landscapeObject = objectChunk.getCacheObject(objectId, objectPosition); - if(!landscapeObject) { - landscapeObject = objectChunk.getAddedObject(objectId, objectPosition); - cacheOriginal = false; - - if(!landscapeObject) { - return; - } - } - - if(objectChunk.getRemovedObject(objectId, objectPosition)) { - return; - } - - const landscapeObjectDefinition = gameCache.landscapeObjectDefinitions.get(objectId); - - const actionIdx = options[packetId].index; - let optionName = `action-${actionIdx + 1}`; - if(landscapeObjectDefinition.options && landscapeObjectDefinition.options.length >= actionIdx) { - if(!landscapeObjectDefinition.options[actionIdx] || landscapeObjectDefinition.options[actionIdx].toLowerCase() === 'hidden') { - // Invalid action - logger.error(`1: Invalid object ${objectId} option ${actionIdx + 1}, options: ${JSON.stringify(landscapeObjectDefinition.options)}`); - return; - } - - optionName = landscapeObjectDefinition.options[actionIdx]; - } else { - // Invalid action - logger.error(`2: Invalid object ${objectId} option ${actionIdx + 1}, options: ${JSON.stringify(landscapeObjectDefinition.options)}`); - return; - } - - objectAction(player, landscapeObject, landscapeObjectDefinition, objectPosition, optionName.toLowerCase(), cacheOriginal); -}; diff --git a/src/world/mob/player/packet/incoming-packet-directory.ts b/src/world/mob/player/packet/incoming-packet-directory.ts deleted file mode 100644 index 8c70a7274..000000000 --- a/src/world/mob/player/packet/incoming-packet-directory.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Player } from '../player'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { logger } from '@runejs/logger'; - -import { incomingPacket } from './incoming-packet'; -import { characterDesignPacket } from './impl/character-design-packet'; -import { itemEquipPacket } from './impl/item-equip-packet'; -import { interfaceClickPacket } from './impl/interface-click-packet'; -import { cameraTurnPacket } from './impl/camera-turn-packet'; -import { buttonClickPacket } from './impl/button-click-packet'; -import { walkPacket } from './impl/walk-packet'; -import { itemOption1Packet } from './impl/item-option-1-packet'; -import { commandPacket } from './impl/command-packet'; -import { itemSwapPacket } from './impl/item-swap-packet'; -import { dialogueInteractionPacket } from '@server/world/mob/player/packet/impl/dialogue-interaction-packet'; -import { npcInteractionPacket } from '@server/world/mob/player/packet/impl/npc-interaction-packet'; -import { objectInteractionPacket } from '@server/world/mob/player/packet/impl/object-interaction-packet'; -import { chatPacket } from '@server/world/mob/player/packet/impl/chat-packet'; -import { dropItemPacket } from '@server/world/mob/player/packet/impl/drop-item-packet'; -import { itemOnItemPacket } from '@server/world/mob/player/packet/impl/item-on-item-packet'; -import { buyItemPacket } from '@server/world/mob/player/packet/impl/buy-item-packet'; - -const packets: { [key: number]: incomingPacket } = { - 19: interfaceClickPacket, - 140: cameraTurnPacket, - - 79: buttonClickPacket, - 226: dialogueInteractionPacket, - - 112: npcInteractionPacket, - 13: npcInteractionPacket, - 42: npcInteractionPacket, - 8: npcInteractionPacket, - 67: npcInteractionPacket, - - 181: objectInteractionPacket, - 241: objectInteractionPacket, - 50: objectInteractionPacket, - 136: objectInteractionPacket, - 55: objectInteractionPacket, - - 28: walkPacket, - 213: walkPacket, - 247: walkPacket, - - 163: characterDesignPacket, - - 24: itemEquipPacket, - 3: itemOption1Packet, - 123: itemSwapPacket, - 4: dropItemPacket, - 1: itemOnItemPacket, - - 49: chatPacket, - 56: commandPacket, - - 177: buyItemPacket, - 91: buyItemPacket, - 231: buyItemPacket -}; - -export function handlePacket(player: Player, packetId: number, packetSize: number, buffer: Buffer): void { - const packetFunction = packets[packetId]; - - if(!packetFunction) { - logger.info(`Unknown packet ${packetId} with size ${packetSize} received.`); - return; - } - - new Promise(resolve => { - packetFunction(player, packetId, packetSize, new RsBuffer(buffer)); - resolve(); - }); -} diff --git a/src/world/mob/player/packet/incoming-packet.ts b/src/world/mob/player/packet/incoming-packet.ts deleted file mode 100644 index e5d272397..000000000 --- a/src/world/mob/player/packet/incoming-packet.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RsBuffer } from '@server/net/rs-buffer'; -import { Player } from '../player'; - -export type incomingPacket = (player: Player, packetId: number, packetSize: number, buffer: RsBuffer) => void; diff --git a/src/world/mob/player/packet/packet-sender.ts b/src/world/mob/player/packet/packet-sender.ts deleted file mode 100644 index 48668e6a2..000000000 --- a/src/world/mob/player/packet/packet-sender.ts +++ /dev/null @@ -1,515 +0,0 @@ -import { Player } from '../player'; -import { Socket } from 'net'; -import { Packet, PacketType } from '@server/net/packet'; -import { ItemContainer } from '@server/world/items/item-container'; -import { Item } from '@server/world/items/item'; -import { Position } from '@server/world/position'; -import { LandscapeObject } from '@runejs/cache-parser'; -import { Chunk, ChunkUpdateItem } from '@server/world/map/chunk'; -import { WorldItem } from '@server/world/items/world-item'; -import { rsTime } from '@server/util/time'; -import { addressToInt } from '@server/util/address'; - -/** - * 6 = set chatbox input type to 2 - * 156 = set minimap state - * 167 = move camera? - * - * - * 220 = play song - * 249 = play overlay song? - * 41 = play sound at position - * - * 61 = reset X reference coordinate - * 75 = update reference position - * 40 = clear map region ground items and objects - * 53 = construct map region - * 222 = send current map region - * 183 = update map region ground items and objects - * 88 = remove landscape object - * 208 = remove ground item - * 152 = set landscape object - * 121 = update ground item amount - * 107 = set ground item - * - * 135 = private message received - * 190 = system update notification - * 63 = send chatbox message - * - * 5 = send logout - * 199 = show mob hint icon - @TODO COME BACK TO THIS - * 13 = reset mob animations - * 90 = player updating - * 71 = npc updating - * 157 = add player option - * 126 = update member status and player index - * - * 59 = show graphics at position - * - * 29 = close all widgets - * 10 = show tab widget - * 76 = show welcome widget - * 159 = show standalone game widget - * 50 = show walkable game widget - * 246 = show standalone sidebar tab widget - * 128 = show game and sidebar tab widget together (for banking and such) - * 109 = show standalone chatbox widget - * 252 = force open sidebar tab - * - * 2 = show widget animation - * 218 = update widget color - * 232 = send widget string - * 238 = flash sidebar tab icon - * 200 = set widget scroll position - * 166 = set widget position - * 82 = set widget hidden until hovered state - * - * 206 = update widget items - * 134 = update specific widget items - * 219 = clear widget items - * 125 = send player run energy - * 174 = update carry weight - * 49 = update player skill - * 78 = send friend info - * 251 = update friend list status - * 226 = update ignore list - * - * 186 = set widget model rotation and zoom - * 21 = show item model on widget - * 216 = show widget media type 1 - * 162 = show npc head on widget? - @TODO COME BACK TO THIS - * 255 = show player head on widget - * - * 201 = update chat settings - * 113 = reset widget settings - * 115 = update large widget setting value - * 182 = update small widget setting value - */ - -/** - * A helper class for sending various network packets back to the game client. - */ -export class PacketSender { - - private readonly player: Player; - private readonly socket: Socket; - - public constructor(player: Player) { - this.player = player; - this.socket = player.socket; - } - - public playSong(songId: number): void { - const packet = new Packet(220); - packet.writeOffsetShortLE(songId); - - this.send(packet); - } - - public playQuickSong(songId: number, previousSongId: number): void { - const packet = new Packet(249); - packet.writeShortLE(songId); - packet.writeMediumME(previousSongId); - - this.send(packet); - } - - public playSound(soundId: number, volume: number, delay: number = 0): void { - const packet = new Packet(26); - packet.writeShortBE(soundId); - packet.writeByte(volume); - packet.writeShortBE(delay); - - this.send(packet); - } - - private getChunkPositionOffset(x: number, y: number, chunk: Chunk): number { - const offsetX = x - ((chunk.position.x + 6) * 8); - const offsetY = y - ((chunk.position.y + 6) * 8); - return (offsetX * 16 + offsetY); - } - - private getChunkOffset(chunk: Chunk): { offsetX: number, offsetY: number } { - let offsetX = (chunk.position.x + 6) * 8; - let offsetY = (chunk.position.y + 6) * 8; - offsetX -= (this.player.lastMapRegionUpdatePosition.chunkX * 8); - offsetY -= (this.player.lastMapRegionUpdatePosition.chunkY * 8); - - return { offsetX, offsetY }; - } - - public updateChunk(chunk: Chunk, chunkUpdates: ChunkUpdateItem[]): void { - const { offsetX, offsetY } = this.getChunkOffset(chunk); - - const packet = new Packet(183, PacketType.DYNAMIC_LARGE); - packet.writeUnsignedByte(offsetX); - packet.writeOffsetByte(offsetY); - - chunkUpdates.forEach(update => { - if(update.type === 'ADD') { - if(update.object) { - const offset = this.getChunkPositionOffset(update.object.x, update.object.y, chunk); - packet.writeUnsignedByte(152); - packet.writeByteInverted((update.object.type << 2) + (update.object.rotation & 3)); - packet.writeOffsetShortLE(update.object.objectId); - packet.writeOffsetByte(offset); - } else if(update.worldItem) { - const offset = this.getChunkPositionOffset(update.worldItem.position.x, update.worldItem.position.y, chunk); - packet.writeUnsignedByte(107); - packet.writeShortBE(update.worldItem.itemId); - packet.writeByteInverted(offset); - packet.writeNegativeOffsetShortBE(update.worldItem.amount); - } - } else if(update.type === 'REMOVE') { - const offset = this.getChunkPositionOffset(update.object.x, update.object.y, chunk); - packet.writeUnsignedByte(88); - packet.writeNegativeOffsetByte(offset); - packet.writeNegativeOffsetByte((update.object.type << 2) + (update.object.rotation & 3)); - } - }); - - this.send(packet); - } - - public clearChunk(chunk: Chunk): void { - const { offsetX, offsetY } = this.getChunkOffset(chunk); - - const packet = new Packet(40); - packet.writeNegativeOffsetByte(offsetY); - packet.writeByteInverted(offsetX); - - this.send(packet); - } - - public setWorldItem(worldItem: WorldItem, position: Position, offset: number = 0): void { - this.updateReferencePosition(position); - - const packet = new Packet(107); - packet.writeShortBE(worldItem.itemId); - packet.writeByteInverted(offset); - packet.writeNegativeOffsetShortBE(worldItem.amount); - - this.send(packet); - } - - public removeWorldItem(worldItem: WorldItem, position: Position, offset: number = 0): void { - this.updateReferencePosition(position); - - const packet = new Packet(208); - packet.writeNegativeOffsetShortBE(worldItem.itemId); - packet.writeOffsetByte(offset); - - this.send(packet); - } - - public setLandscapeObject(landscapeObject: LandscapeObject, position: Position, offset: number = 0): void { - this.updateReferencePosition(position); - - const packet = new Packet(152); - packet.writeByteInverted((landscapeObject.type << 2) + (landscapeObject.rotation & 3)); - packet.writeOffsetShortLE(landscapeObject.objectId); - packet.writeOffsetByte(offset); - - this.send(packet); - } - - public removeLandscapeObject(landscapeObject: LandscapeObject, position: Position, offset: number = 0): void { - this.updateReferencePosition(position); - - const packet = new Packet(88); - packet.writeNegativeOffsetByte(offset); - packet.writeNegativeOffsetByte((landscapeObject.type << 2) + (landscapeObject.rotation & 3)); - - this.send(packet); - } - - public updateReferencePosition(position: Position): void { - const offsetX = position.x - (this.player.lastMapRegionUpdatePosition.chunkX * 8); - const offsetY = position.y - (this.player.lastMapRegionUpdatePosition.chunkY * 8); - - const packet = new Packet(75); - packet.writeByteInverted(offsetX); - packet.writeOffsetByte(offsetY); - - this.send(packet); - } - - public playWidgetAnimation(widgetId: number, animationId: number): void { - const packet = new Packet(2); - packet.writeNegativeOffsetShortLE(widgetId); - packet.writeNegativeOffsetShortBE(animationId); - - this.send(packet); - } - - // NPC dialogs = 4882, 4887, 4893, 4900 - // Player dialogs = 968, 973, 979, 986 - // Text dialogs = 356, 359, 363, 368, 374 - // Item dialogs = 306, 310, 315, 321 - // Statements (no click to continue) = 12788, 12790, 12793, 12797, 6179 - // Options = 2459, 2469, 2480, 2492 - public showChatboxWidget(widgetId: number): void { - const packet = new Packet(109); - packet.writeShortBE(widgetId); - - this.send(packet); - } - - public setWidgetModel2(widgetId: number, modelId: number): void { - const packet = new Packet(162); - packet.writeNegativeOffsetShortBE(modelId); - packet.writeShortLE(widgetId); - - this.send(packet); - } - - public setWidgetPlayerHead(widgetId: number): void { - const packet = new Packet(255); - packet.writeNegativeOffsetShortLE(widgetId); - - this.send(packet); - } - - public updateWidgetSetting(settingId: number, value: number): void { - let packet: Packet; - - if(value > 255) { - // @TODO large settings values - packet 115? - } else { - packet = new Packet(182); - packet.writeOffsetShortBE(settingId); - packet.writeNegativeOffsetByte(value); - } - - this.send(packet); - } - - public updateWidgetItemModel(widgetId: number, itemId: number, scale?: number): void { - const packet = new Packet(21); - packet.writeShortBE(scale); - packet.writeShortLE(itemId); - packet.writeOffsetShortLE(widgetId); - - this.send(packet); - } - - public updateWidgetString(widgetId: number, value: string): void { - const packet = new Packet(232, PacketType.DYNAMIC_LARGE); - packet.writeOffsetShortLE(widgetId); - packet.writeString(value); - - this.send(packet); - } - - public closeActiveWidgets(): void { - this.send(new Packet(29)); - } - - public showScreenWidget(widgetId: number): void { - const packet = new Packet(159); - packet.writeOffsetShortLE(widgetId); - - this.send(packet); - } - - public sendUpdateSingleWidgetItem(widgetId: number, slot: number, item: Item): void { - const packet = new Packet(134, PacketType.DYNAMIC_LARGE); - packet.writeUnsignedShortBE(widgetId); - packet.writeSmart(slot); - - if(!item) { - packet.writeUnsignedShortBE(0); - packet.writeUnsignedByte(0); - } else { - packet.writeUnsignedShortBE(item.itemId + 1); // +1 because 0 means an empty slot - - if(item.amount >= 255) { - packet.writeUnsignedByte(255); - packet.writeIntBE(item.amount); - } else { - packet.writeUnsignedByte(item.amount); - } - } - - this.send(packet); - } - - public sendUpdateAllWidgetItems(widgetId: number, container: ItemContainer): void { - const packet = new Packet(206, PacketType.DYNAMIC_LARGE); - packet.writeShortBE(widgetId); - packet.writeShortBE(container.size); - - const items = container.items; - items.forEach(item => { - if(!item) { - // Empty slot - packet.writeOffsetShortLE(0); - packet.writeUnsignedByteInverted(-1); - } else { - packet.writeOffsetShortLE(item.itemId + 1); // +1 because 0 means an empty slot - - if(item.amount >= 255) { - packet.writeUnsignedByteInverted(254); - packet.writeIntLE(item.amount); - } else { - packet.writeUnsignedByteInverted(item.amount - 1); - } - } - }); - - this.send(packet); - } - - public sendUpdateAllWidgetItemsById(widgetId: number, itemIds: number[]): void { - const packet = new Packet(206, PacketType.DYNAMIC_LARGE); - packet.writeShortBE(widgetId); - packet.writeShortBE(itemIds.length); - - itemIds.forEach(itemId => { - if(!itemId) { - // Empty slot - packet.writeOffsetShortLE(0); - packet.writeByteInverted(0); - } else { - packet.writeOffsetShortLE(itemId + 1); // +1 because 0 means an empty slot - packet.writeByteInverted(1); - } - }); - - this.send(packet); - } - - public toggleWidgetVisibility(widgetId: number, hidden: boolean): void { - const packet = new Packet(82); - packet.writeUnsignedByte(hidden ? 1 : 0); - packet.writeShortBE(widgetId); - - this.send(packet); - } - - public sendTabWidget(tabIndex: number, widgetId: number): void { - const packet = new Packet(10); - packet.writeNegativeOffsetByte(tabIndex); - packet.writeOffsetShortBE(widgetId); - - this.send(packet); - } - - public showFullscreenWidget(widgetId: number, childWidgetId: number): void { - const packet = new Packet(253); - packet.writeUnsignedShortLE(childWidgetId); - packet.writeOffsetShortBE(widgetId); - - this.send(packet); - } - - public updateWelcomeScreenInfo(childId: number, lastLogin: Date, lastAddress: string): void { - const currentTime = rsTime(new Date()); - - this.updateWidgetString(15270, `\\nYou do not have a Bank PIN.\\nPlease visit a bank if you would like one.`); - this.updateWidgetString(childId + 2, `Interested in helping RuneJS improve?`); - this.updateWidgetString(childId + 3, `Send us a Pull Request over on Github!`); - // @TODO reminder that welcome screen models can be changed :) - - const packet = new Packet(76); - packet.writeUnsignedShortLE(0); // last password change time - packet.writeOffsetShortLE(3); // junk - packet.writeShortBE(4); // junk - packet.writeShortBE(5); // junk - packet.writeUnsignedShortLE(currentTime); // long screen display time - packet.writeOffsetShortBE(0); // unread website message count - packet.writeUnsignedOffsetShortBE(lastLogin === undefined || lastLogin === null ? currentTime : rsTime(lastLogin)); // last login time - packet.writeShortBE(42); // membership credit days remaining - packet.writeIntLE(addressToInt(lastAddress)); // last login IP/address - packet.writeOffsetShortLE(0); // recovery question set time - packet.writeOffsetByte(12); // junk - - this.send(packet); - } - - /** - * Clears the player's current map chunk of all ground items and spawned/modified landscape objects. - */ - public clearMapChunk(): void { - const packet = new Packet(40); - packet.writeNegativeOffsetByte(this.player.position.chunkY + 6); // Map Chunk Y - packet.writeByteInverted(this.player.position.chunkX + 6); // Map Chunk X - - this.send(packet); - } - - public updateCarryWeight(weight: number): void { - const packet = new Packet(174); - packet.writeShortBE(weight); - - this.send(packet); - } - - public showHintIcon(iconType: 2 | 3 | 4 | 5 | 6, position: Position, offset: number = 0): void { - const packet = new Packet(199); - packet.writeUnsignedByte(iconType); - packet.writeUnsignedShortBE(position.x); - packet.writeUnsignedShortBE(position.y); - packet.writeUnsignedByte(offset); - - this.send(packet); - } - - public showPlayerHintIcon(player: Player): void { - const packet = new Packet(199); - packet.writeUnsignedByte(10); - packet.writeUnsignedShortBE(player.worldIndex); - - // Packet requires a length of 6, so send some extra junk - packet.writeByte(0); - packet.writeByte(0); - packet.writeByte(0); - - this.send(packet); - } - - public sendLogout(): void { - this.send(new Packet(5)); - } - - public chatboxMessage(message: string): void { - const packet = new Packet(63, PacketType.DYNAMIC_SMALL); - packet.writeString(message); - - this.send(packet); - } - - public sendSkill(skillId: number, level: number, exp: number): void { - const packet = new Packet(49); - packet.writeByteInverted(skillId); - packet.writeUnsignedByte(level); - packet.writeIntBE(exp); - - this.send(packet); - } - - public updateCurrentMapChunk(): void { - const packet = new Packet(222); - packet.writeShortBE(this.player.position.chunkY + 6); // Map Chunk Y - packet.writeOffsetShortLE(this.player.position.chunkX + 6); // Map Chunk X - - this.send(packet); - } - - public sendMembershipStatusAndWorldIndex(): void { - const packet = new Packet(126); - packet.writeUnsignedByte(1); // @TODO member status - packet.writeShortLE(this.player.worldIndex + 1); - - this.send(packet); - } - - public send(packet: Packet): void { - if(!this.socket || this.socket.destroyed) { - return; - } - - this.socket.write(packet.toBuffer(this.player.outCipher)); - } - -} diff --git a/src/world/mob/player/player.ts b/src/world/mob/player/player.ts deleted file mode 100644 index 29eeea084..000000000 --- a/src/world/mob/player/player.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { AddressInfo, Socket } from 'net'; -import { PacketSender } from './packet/packet-sender'; -import { Isaac } from '@server/net/isaac'; -import { PlayerUpdateTask } from './updating/player-update-task'; -import { Mob } from '../mob'; -import { Position } from '@server/world/position'; -import { serverConfig, world } from '@server/game-server'; -import { logger } from '@runejs/logger'; -import { - Appearance, - defaultAppearance, defaultSettings, - loadPlayerSave, - PlayerSave, PlayerSettings, - savePlayerData -} from './player-data'; -import { ActiveWidget, widgetIds, widgetSettings } from './widget'; -import { ContainerUpdateEvent, ItemContainer } from '../../items/item-container'; -import { EquipmentBonuses, ItemDetails } from '../../config/item-data'; -import { Item } from '../../items/item'; -import { Npc } from '../npc/npc'; -import { NpcUpdateTask } from './updating/npc-update-task'; -import { Subject } from 'rxjs'; -import { Chunk, ChunkUpdateItem } from '@server/world/map/chunk'; -import { QuadtreeKey } from '@server/world/world'; - -const DEFAULT_TAB_WIDGET_IDS = [ - 2423, 3917, 638, 3213, 1644, 5608, 1151, -1, 5065, 5715, 2449, 904, 147, 962 -]; - -export enum Rights { - ADMIN = 2, - MOD = 1, - USER = 0 -} - -/** - * A player character within the game world. - */ -export class Player extends Mob { - - private readonly _socket: Socket; - private readonly _inCipher: Isaac; - private readonly _outCipher: Isaac; - public readonly clientUuid: number; - public readonly username: string; - private readonly password: string; - private _rights: Rights; - private loggedIn: boolean; - private _loginDate: Date; - private _lastAddress: string; - public isLowDetail: boolean; - private firstTimePlayer: boolean; - private readonly _packetSender: PacketSender; - public readonly playerUpdateTask: PlayerUpdateTask; - public readonly npcUpdateTask: NpcUpdateTask; - public trackedPlayers: Player[]; - public trackedNpcs: Npc[]; - private _appearance: Appearance; - private _activeWidget: ActiveWidget; - private readonly _equipment: ItemContainer; - private _bonuses: EquipmentBonuses; - private _carryWeight: number; - private _settings: PlayerSettings; - public readonly dialogueInteractionEvent: Subject; - private _walkingTo: Position; - private _nearbyChunks: Chunk[]; - public readonly actionsCancelled: Subject; - private quadtreeKey: QuadtreeKey = null; - - public constructor(socket: Socket, inCipher: Isaac, outCipher: Isaac, clientUuid: number, username: string, password: string, isLowDetail: boolean) { - super(); - this._socket = socket; - this._inCipher = inCipher; - this._outCipher = outCipher; - this.clientUuid = clientUuid; - this.username = username; - this.password = password; - this._rights = Rights.ADMIN; - this.isLowDetail = isLowDetail; - this._packetSender = new PacketSender(this); - this.playerUpdateTask = new PlayerUpdateTask(this); - this.npcUpdateTask = new NpcUpdateTask(this); - this.trackedPlayers = []; - this.trackedNpcs = []; - this._activeWidget = null; - this._carryWeight = 0; - this._equipment = new ItemContainer(14); - this.dialogueInteractionEvent = new Subject(); - this._nearbyChunks = []; - this.actionsCancelled = new Subject(); - - this.loadSaveData(); - } - - private loadSaveData(): void { - const playerSave: PlayerSave = loadPlayerSave(this.username); - const firstTimePlayer: boolean = playerSave === null; - this.firstTimePlayer = firstTimePlayer; - - if(!firstTimePlayer) { - // Existing player logging in - this.position = new Position(playerSave.position.x, playerSave.position.y, playerSave.position.level); - if(playerSave.inventory && playerSave.inventory.length !== 0) { - this.inventory.setAll(playerSave.inventory); - } - if(playerSave.equipment && playerSave.equipment.length !== 0) { - this.equipment.setAll(playerSave.equipment); - } - if(playerSave.skills && playerSave.skills.length !== 0) { - this.skills.values = playerSave.skills; - } - this._appearance = playerSave.appearance; - this._settings = playerSave.settings; - this._rights = playerSave.rights || Rights.USER; - - const lastLogin = playerSave.lastLogin?.date; - if(!lastLogin) { - this._loginDate = new Date(); - } else { - this._loginDate = new Date(lastLogin); - } - - this._lastAddress = playerSave.lastLogin?.address || (this._socket?.address() as AddressInfo)?.address || '127.0.0.1'; - } else { - // Brand new player logging in - this.position = new Position(3222, 3222); - this.inventory.add({itemId: 1351, amount: 1}); - this.inventory.add({itemId: 1048, amount: 1}); - this.inventory.add({itemId: 6623, amount: 1}); - this.inventory.add({itemId: 1079, amount: 1}); - this.inventory.add({itemId: 1127, amount: 1}); - this.inventory.add({itemId: 1303, amount: 1}); - this.inventory.add({itemId: 1319, amount: 1}); - this.inventory.add({itemId: 1201, amount: 1}); - this._appearance = defaultAppearance(); - this._rights = Rights.USER; - } - - if(!this._settings) { - this._settings = defaultSettings(); - } - } - - public init(): void { - this.loggedIn = true; - this.updateFlags.mapRegionUpdateRequired = true; - this.updateFlags.appearanceUpdateRequired = true; - - const playerChunk = world.chunkManager.getChunkForWorldPosition(this.position); - playerChunk.addPlayer(this); - - this.packetSender.sendMembershipStatusAndWorldIndex(); - this.packetSender.updateCurrentMapChunk(); - this.chunkChanged(playerChunk); - this.packetSender.chatboxMessage('Welcome to RuneScape.'); - - DEFAULT_TAB_WIDGET_IDS.forEach((widgetId: number, tabIndex: number) => { - if(widgetId !== -1) { - this.packetSender.sendTabWidget(tabIndex, widgetId); - } - }); - - this.skills.values.forEach((skill, index) => this.packetSender.sendSkill(index, skill.level, skill.exp)); - - this.packetSender.sendUpdateAllWidgetItems(widgetIds.inventory, this.inventory); - this.packetSender.sendUpdateAllWidgetItems(widgetIds.equipment, this.equipment); - - if(this.firstTimePlayer) { - this.activeWidget = { - widgetId: widgetIds.characterDesign, - type: 'SCREEN', - disablePlayerMovement: true - }; - } else if(serverConfig.showWelcome) { - this.packetSender.updateWelcomeScreenInfo(widgetIds.welcomeScreenChildren.question, this.loginDate, this.lastAddress); - - this.activeWidget = { - widgetId: widgetIds.welcomeScreen, - childWidgetId: widgetIds.welcomeScreenChildren.question, - type: 'FULLSCREEN' - }; - } - - this.updateBonuses(); - this.updateWidgetSettings(); - this.updateCarryWeight(true); - - this.inventory.containerUpdated.subscribe(event => this.inventoryUpdated(event)); - - this.actionsCancelled.subscribe(doNotCloseWidgets => { - if(!doNotCloseWidgets) { - this.packetSender.closeActiveWidgets(); - this._activeWidget = null; - } - }); - - this._loginDate = new Date(); - this._lastAddress = (this._socket?.address() as AddressInfo)?.address || '127.0.0.1'; - - logger.info(`${this.username}:${this.worldIndex} has logged in.`); - } - - public logout(): void { - if(!this.loggedIn) { - return; - } - - world.playerTree.remove(this.quadtreeKey); - savePlayerData(this); - - this.packetSender.sendLogout(); - world.chunkManager.getChunkForWorldPosition(this.position).removePlayer(this); - world.deregisterPlayer(this); - this.loggedIn = false; - - logger.info(`${this.username} has logged out.`); - } - - /** - * Should be fired whenever the player's chunk changes. This will fire off chunk updates for all chunks not - * already tracked by the player - all the new chunks that are coming into view. - * @param chunk The player's new active map chunk. - */ - public chunkChanged(chunk: Chunk): void { - const nearbyChunks = world.chunkManager.getSurroundingChunks(chunk); - if(this._nearbyChunks.length === 0) { - this.sendChunkUpdates(nearbyChunks); - } else { - const newChunks = nearbyChunks.filter(c1 => this._nearbyChunks.findIndex(c2 => c1.equals(c2)) === -1); - this.sendChunkUpdates(newChunks); - } - - this._nearbyChunks = nearbyChunks; - } - - /** - * Sends chunk updates to notify the client of added & removed landscape objects - * @param chunks The chunks to update. - */ - private sendChunkUpdates(chunks: Chunk[]): void { - chunks.forEach(chunk => { - this.packetSender.clearChunk(chunk); - - const chunkUpdateItems: ChunkUpdateItem[] = []; - - if(chunk.removedLandscapeObjects.size !== 0) { - chunk.removedLandscapeObjects.forEach(object => chunkUpdateItems.push({ object, type: 'REMOVE' })); - } - - if(chunk.addedLandscapeObjects.size !== 0) { - chunk.addedLandscapeObjects.forEach(object => chunkUpdateItems.push({ object, type: 'ADD' })); - } - - if(chunk.worldItems.size !== 0) { - chunk.worldItems.forEach(worldItemList => { - if(worldItemList && worldItemList.length !== 0) { - worldItemList.forEach(worldItem => { - if(!worldItem.initiallyVisibleTo || worldItem.initiallyVisibleTo.equals(this)) { - chunkUpdateItems.push({worldItem, type: 'ADD'}); - } - }); - } - }); - } - - if(chunkUpdateItems.length !== 0) { - this.packetSender.updateChunk(chunk, chunkUpdateItems); - } - }); - } - - public async tick(): Promise { - return new Promise(resolve => { - this.walkingQueue.process(); - - if(this.updateFlags.mapRegionUpdateRequired) { - this.packetSender.updateCurrentMapChunk(); - } - - resolve(); - }); - } - - public async reset(): Promise { - return new Promise(resolve => { - this.updateFlags.reset(); - - if(this.metadata['updateChunk']) { - const { newChunk, oldChunk } = this.metadata['updateChunk']; - oldChunk.removePlayer(this); - newChunk.addPlayer(this); - this.chunkChanged(newChunk); - this.metadata['updateChunk'] = null; - } - - if(this.metadata['teleporting']) { - this.metadata['teleporting'] = null; - } - - resolve(); - }); - } - - public teleport(newPosition: Position): void { - const oldChunk = world.chunkManager.getChunkForWorldPosition(this.position); - const newChunk = world.chunkManager.getChunkForWorldPosition(newPosition); - - this.walkingQueue.clear(); - this.position = newPosition; - - this.updateFlags.mapRegionUpdateRequired = true; - this.lastMapRegionUpdatePosition = newPosition; - this.metadata['teleporting'] = true; - - if(!oldChunk.equals(newChunk)) { - this.metadata['updateChunk'] = { newChunk, oldChunk }; - } - } - - public canMove(): boolean { - return true; - } - - public removeFirstItem(item: number | Item): number { - const slot = this.inventory.removeFirst(item); - - if(slot === -1) { - return -1; - } - - this.packetSender.sendUpdateSingleWidgetItem(widgetIds.inventory, slot, null); - return slot; - } - - public removeItem(slot: number): void { - this.inventory.remove(slot); - - this.packetSender.sendUpdateSingleWidgetItem(widgetIds.inventory, slot, null); - } - - public giveItem(item: number | Item): boolean { - const addedItem = this.inventory.add(item); - if(addedItem === null) { - return false; - } - - this.packetSender.sendUpdateSingleWidgetItem(widgetIds.inventory, addedItem.slot, addedItem.item); - return true; - } - - public hasItemInEquipment(item: number | Item): boolean { - return this._equipment.has(item); - } - - public hasItemOnPerson(item: number | Item): boolean { - return this.hasItemInInventory(item) || this.hasItemInEquipment(item); - } - - private inventoryUpdated(event: ContainerUpdateEvent): void { - this.updateCarryWeight(); - } - - public updateCarryWeight(force: boolean = false): void { - const oldWeight = this._carryWeight; - this._carryWeight = Math.round(this.inventory.weight() + this.equipment.weight()); - - if(oldWeight !== this._carryWeight || force) { - this.packetSender.updateCarryWeight(this._carryWeight); - } - } - - public settingChanged(buttonId: number): void { - const settingsMappings = { - 152: {setting: 'runEnabled', value: false}, - 153: {setting: 'runEnabled', value: true}, - 930: {setting: 'musicVolume', value: 4}, - 931: {setting: 'musicVolume', value: 3}, - 932: {setting: 'musicVolume', value: 2}, - 933: {setting: 'musicVolume', value: 1}, - 934: {setting: 'musicVolume', value: 0}, - 941: {setting: 'soundEffectVolume', value: 4}, - 942: {setting: 'soundEffectVolume', value: 3}, - 943: {setting: 'soundEffectVolume', value: 2}, - 944: {setting: 'soundEffectVolume', value: 1}, - 945: {setting: 'soundEffectVolume', value: 0}, - 957: {setting: 'splitPrivateChatEnabled', value: true}, - 958: {setting: 'splitPrivateChatEnabled', value: false}, - 913: {setting: 'twoMouseButtonsEnabled', value: true}, - 914: {setting: 'twoMouseButtonsEnabled', value: false}, - 906: {setting: 'screenBrightness', value: 1}, - 908: {setting: 'screenBrightness', value: 2}, - 910: {setting: 'screenBrightness', value: 3}, - 912: {setting: 'screenBrightness', value: 4}, - 915: {setting: 'chatEffectsEnabled', value: true}, - 916: {setting: 'chatEffectsEnabled', value: false}, - 12464: {setting: 'acceptAidEnabled', value: true}, - 12465: {setting: 'acceptAidEnabled', value: false}, - 150: {setting: 'autoRetaliateEnabled', value: true}, - 151: {setting: 'autoRetaliateEnabled', value: false} - }; - - if(!settingsMappings.hasOwnProperty(buttonId)) { - return; - } - - const config = settingsMappings[buttonId]; - this.settings[config.setting] = config.value; - } - - public updateWidgetSettings(): void { - const settings = this.settings; - this.packetSender.updateWidgetSetting(widgetSettings.brightness, settings.screenBrightness); - this.packetSender.updateWidgetSetting(widgetSettings.mouseButtons, settings.twoMouseButtonsEnabled ? 0 : 1); - this.packetSender.updateWidgetSetting(widgetSettings.splitPrivateChat, settings.splitPrivateChatEnabled ? 1 : 0); - this.packetSender.updateWidgetSetting(widgetSettings.chatEffects, settings.chatEffectsEnabled ? 0 : 1); - this.packetSender.updateWidgetSetting(widgetSettings.acceptAid, settings.acceptAidEnabled ? 1 : 0); - this.packetSender.updateWidgetSetting(widgetSettings.musicVolume, settings.musicVolume); - this.packetSender.updateWidgetSetting(widgetSettings.soundEffectVolume, settings.soundEffectVolume); - this.packetSender.updateWidgetSetting(widgetSettings.runMode, settings.runEnabled ? 1 : 0); - this.packetSender.updateWidgetSetting(widgetSettings.autoRetaliate, settings.autoRetaliateEnabled ? 0 : 1); - } - - public updateBonuses(): void { - this.clearBonuses(); - - for(const item of this._equipment.items) { - if(item === null) { - continue; - } - - this.addBonuses(item); - } - - [ - { id: 1675, text: 'Stab', value: this._bonuses.offencive.stab }, - { id: 1676, text: 'Slash', value: this._bonuses.offencive.slash }, - { id: 1677, text: 'Crush', value: this._bonuses.offencive.crush }, - { id: 1678, text: 'Magic', value: this._bonuses.offencive.magic }, - { id: 1679, text: 'Range', value: this._bonuses.offencive.ranged }, - { id: 1680, text: 'Stab', value: this._bonuses.defencive.stab }, - { id: 1681, text: 'Slash', value: this._bonuses.defencive.slash }, - { id: 1682, text: 'Crush', value: this._bonuses.defencive.crush }, - { id: 1683, text: 'Magic', value: this._bonuses.defencive.magic }, - { id: 1684, text: 'Range', value: this._bonuses.defencive.ranged }, - { id: 1686, text: 'Strength', value: this._bonuses.skill.strength }, - { id: 1687, text: 'Prayer', value: this._bonuses.skill.prayer }, - ].forEach(bonus => this.updateBonusString(bonus.id, bonus.text, bonus.value)); - } - - private updateBonusString(widgetChildId: number, text: string, value: number): void { - const s = `${text}: ${value > 0 ? `+${value}` : value}`; - this.packetSender.updateWidgetString(widgetChildId, s); - } - - private addBonuses(item: Item): void { - const itemData: ItemDetails = world.itemData.get(item.itemId); - - if(!itemData || !itemData.equipment || !itemData.equipment.bonuses) { - return; - } - - const bonuses = itemData.equipment.bonuses; - - if(bonuses.offencive) { - [ 'speed', 'stab', 'slash', 'crush', 'magic', 'ranged' ].forEach(bonus => this._bonuses.offencive[bonus] += (!bonuses.offencive.hasOwnProperty(bonus) ? 0 : bonuses.offencive[bonus])); - } - - if(bonuses.defencive) { - [ 'stab', 'slash', 'crush', 'magic', 'ranged' ].forEach(bonus => this._bonuses.defencive[bonus] += (!bonuses.defencive.hasOwnProperty(bonus) ? 0 : bonuses.defencive[bonus])); - } - - if(bonuses.skill) { - [ 'strength', 'prayer' ].forEach(bonus => this._bonuses.skill[bonus] += (!bonuses.skill.hasOwnProperty(bonus) ? 0 : bonuses.skill[bonus])); - } - } - - private clearBonuses(): void { - this._bonuses = { - offencive: { - speed: 0, stab: 0, slash: 0, crush: 0, magic: 0, ranged: 0 - }, - defencive: { - stab: 0, slash: 0, crush: 0, magic: 0, ranged: 0 - }, - skill: { - strength: 0, prayer: 0 - } - }; - } - - public closeActiveWidget(): void { - this.activeWidget = null; - } - - public set position(position: Position) { - super.position = position; - - if(this.quadtreeKey !== null) { - world.playerTree.remove(this.quadtreeKey); - } - - this.quadtreeKey = { x: position.x, y: position.y, mob: this }; - world.playerTree.push(this.quadtreeKey); - } - - public get position(): Position { - return super.position; - } - - public equals(player: Player): boolean { - return this.worldIndex === player.worldIndex && this.username === player.username && this.clientUuid === player.clientUuid; - } - - public get socket(): Socket { - return this._socket; - } - - public get inCipher(): Isaac { - return this._inCipher; - } - - public get outCipher(): Isaac { - return this._outCipher; - } - - public get packetSender(): PacketSender { - return this._packetSender; - } - - public get loginDate(): Date { - return this._loginDate; - } - - public get lastAddress(): string { - return this._lastAddress; - } - - public get rights(): Rights { - return this._rights; - } - - public get appearance(): Appearance { - return this._appearance; - } - - public set appearance(value: Appearance) { - this._appearance = value; - } - - public get activeWidget(): ActiveWidget { - return this._activeWidget; - } - - public set activeWidget(value: ActiveWidget) { - if(value !== null) { - if(value.type === 'SCREEN') { - this.packetSender.showScreenWidget(value.widgetId); - } else if(value.type === 'CHAT') { - this.packetSender.showChatboxWidget(value.widgetId); - } else if(value.type === 'FULLSCREEN') { - this.packetSender.showFullscreenWidget(value.widgetId, value.childWidgetId); - } - } else { - this.packetSender.closeActiveWidgets(); - } - - this.actionsCancelled.next(true); - this._activeWidget = value; - } - - public get equipment(): ItemContainer { - return this._equipment; - } - - public get carryWeight(): number { - return this._carryWeight; - } - - public get settings(): PlayerSettings { - return this._settings; - } - - public get walkingTo(): Position { - return this._walkingTo; - } - - public set walkingTo(value: Position) { - this._walkingTo = value; - } - - public get nearbyChunks(): Chunk[] { - return this._nearbyChunks; - } -} diff --git a/src/world/mob/player/updating/mob-updating.ts b/src/world/mob/player/updating/mob-updating.ts deleted file mode 100644 index 39f59e162..000000000 --- a/src/world/mob/player/updating/mob-updating.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { RsBuffer } from '@server/net/rs-buffer'; -import { Mob } from '@server/world/mob/mob'; -import { world } from '@server/game-server'; -import { Packet } from '@server/net/packet'; -import { Npc } from '@server/world/mob/npc/npc'; -import { Player } from '../player'; -import { Position } from '@server/world/position'; -import { QuadtreeKey } from '@server/world/world'; - -/** - * Handles the registration of nearby NPCs or Players for the specified player. - */ -export function registerNewMobs(packet: Packet, player: Player, trackedMobs: Mob[], nearbyMobs: QuadtreeKey[], registerMob: (mob: Mob) => void): void { - if(trackedMobs.length >= 255) { - return; - } - - // We only want to send about 20 new mobs at a time, to help save some memory and computing time - // Any remaining players or npcs will be automatically picked up by subsequent updates - let newMobs: QuadtreeKey[] = nearbyMobs.filter(m1 => !trackedMobs.includes(m1.mob)); - if(newMobs.length > 50) { - // We also sort the list of players or npcs here by how close they are to the current player if there are more than 80, so we can render the nearest first - newMobs = newMobs - .sort((a, b) => player.position.distanceBetween(a.mob.position) - player.position.distanceBetween(b.mob.position)) - .slice(0, 50); - } - - for(const newMob of newMobs) { - const nearbyMob = newMob.mob; - - if(nearbyMob instanceof Player) { - if(player.equals(nearbyMob)) { - // Other player is actually this player! - continue; - } - - if(!world.playerExists(nearbyMob)) { - // Other player is no longer in the game world - continue; - } - } else if(nearbyMob instanceof Npc) { - if(!world.npcExists(nearbyMob)) { - // Npc is no longer in the game world - continue; - } - } - - if(trackedMobs.findIndex(m => m.equals(nearbyMob)) !== -1) { - // Npc or other player is already tracked by this player - continue; - } - - if(!nearbyMob.position.withinViewDistance(player.position)) { - // Player or npc is still too far away to be worth rendering - // Also - values greater than 15 and less than -15 are too large, or too small, to be sent via 5 bits (max length of 32) - continue; - } - - // Only 255 players or npcs are able to be rendered at a time - // To help performance, we limit it to 200 here - if(trackedMobs.length >= 255) { - return; - } - - registerMob(nearbyMob); - } -} - -/** - * Handles updating of nearby NPCs or Players for the specified player. - */ -export function updateTrackedMobs(packet: Packet, playerPosition: Position, appendUpdateMaskData: (mob: Mob) => void, trackedMobs: Mob[], nearbyMobs: QuadtreeKey[]): Mob[] { - packet.writeBits(8, trackedMobs.length); // Tracked mob count - - if(trackedMobs.length === 0) { - return []; - } - - const existingTrackedMobs: Mob[] = []; - - for(let i = 0; i < trackedMobs.length; i++) { - const trackedMob: Mob = trackedMobs[i]; - let exists = true; - - if(trackedMob instanceof Player) { - if(!world.playerExists(trackedMob as Player)) { - exists = false; - } - } else { - if(!world.npcExists(trackedMob as Npc)) { - exists = false; - } - } - - if(exists && nearbyMobs.findIndex(m => m.mob.equals(trackedMob)) !== -1 - && trackedMob.position.withinViewDistance(playerPosition)) { - appendMovement(trackedMob, packet); - appendUpdateMaskData(trackedMob); - existingTrackedMobs.push(trackedMob); - } else { - packet.writeBits(1, 1); - packet.writeBits(2, 3); - } - } - - return existingTrackedMobs; -} - -/** - * Applends movement data of a player or NPC to the specified updating packet. - */ -export function appendMovement(mob: Mob, packet: RsBuffer): void { - if(mob.walkDirection !== -1) { - // Mob is walking/running - packet.writeBits(1, 1); // Update required - - if(mob.runDirection === -1) { - // Mob is walking - packet.writeBits(2, 1); // Mob walking - packet.writeBits(3, mob.walkDirection); - } else { - // Mob is running - packet.writeBits(2, 2); // Mob running - packet.writeBits(3, mob.walkDirection); - packet.writeBits(3, mob.runDirection); - } - - packet.writeBits(1, mob.updateFlags.updateBlockRequired ? 1 : 0); // Whether or not an update flag block follows - } else { - // Did not move - if(mob.updateFlags.updateBlockRequired) { - packet.writeBits(1, 1); // Update required - packet.writeBits(2, 0); // Signify the player did not move - } else { - packet.writeBits(1, 0); // No update required - } - } -} diff --git a/src/world/mob/player/updating/npc-update-task.ts b/src/world/mob/player/updating/npc-update-task.ts deleted file mode 100644 index 8612b2892..000000000 --- a/src/world/mob/player/updating/npc-update-task.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Task } from '@server/task/task'; -import { Player } from '../player'; -import { Packet, PacketType } from '@server/net/packet'; -import { RsBuffer } from '@server/net/rs-buffer'; -import { Npc } from '@server/world/mob/npc/npc'; -import { world } from '@server/game-server'; -import { registerNewMobs, updateTrackedMobs } from './mob-updating'; - -/** - * Handles the chonky npc updating packet for a specific player. - */ -export class NpcUpdateTask extends Task { - - private readonly player: Player; - - public constructor(player: Player) { - super(); - this.player = player; - } - - public async execute(): Promise { - return new Promise(resolve => { - const npcUpdatePacket: Packet = new Packet(71, PacketType.DYNAMIC_LARGE); - npcUpdatePacket.openBitChannel(); - - const currentMapChunk = world.chunkManager.getChunkForWorldPosition(this.player.position); - const updateMaskData = RsBuffer.create(); - - const nearbyNpcs = world.npcTree.colliding({ - x: this.player.position.x - 15, - y: this.player.position.y - 15, - width: 32, - height: 32 - }); - - this.player.trackedNpcs = updateTrackedMobs(npcUpdatePacket, this.player.position, - mob => this.appendUpdateMaskData(mob as Npc, updateMaskData), this.player.trackedNpcs, nearbyNpcs) as Npc[]; - - registerNewMobs(npcUpdatePacket, this.player, this.player.trackedNpcs, nearbyNpcs, mob => { - const newNpc = mob as Npc; - const positionOffsetX = newNpc.position.x - this.player.position.x; - const positionOffsetY = newNpc.position.y - this.player.position.y; - - // Add npc to this player's list of tracked npcs - this.player.trackedNpcs.push(newNpc); - - // Notify the client of the new npc and their worldIndex - npcUpdatePacket.writeBits(14, newNpc.worldIndex); - npcUpdatePacket.writeBits(1, newNpc.updateFlags.updateBlockRequired ? 1 : 0); // Update is required - npcUpdatePacket.writeBits(5, positionOffsetY); // World Position Y axis offset relative to the player - npcUpdatePacket.writeBits(5, positionOffsetX); // World Position X axis offset relative to the player - npcUpdatePacket.writeBits(1, 1); // Discard client walking queues - npcUpdatePacket.writeBits(13, newNpc.id); - - this.appendUpdateMaskData(newNpc, updateMaskData); - }); - - if(updateMaskData.getWriterIndex() !== 0) { - npcUpdatePacket.writeBits(14, 16383); - npcUpdatePacket.closeBitChannel(); - - npcUpdatePacket.writeBytes(updateMaskData); - } else { - // No npc updates were appended, so just end the packet here - npcUpdatePacket.closeBitChannel(); - } - - new Promise(resolve => { - this.player.packetSender.send(npcUpdatePacket); - resolve(); - }); - - resolve(); - }); - } - - private appendUpdateMaskData(npc: Npc, updateMaskData: RsBuffer): void { - const updateFlags = npc.updateFlags; - if(!updateFlags.updateBlockRequired) { - return; - } - - let mask = 0; - - if(updateFlags.faceMob !== undefined) { - mask |= 0x40; - } - if(updateFlags.chatMessages.length !== 0) { - mask |= 0x20; - } - if(updateFlags.facePosition) { - mask |= 0x8; - } - if(updateFlags.animation) { - mask |= 0x2; - } - - updateMaskData.writeUnsignedByte(mask); - - if(updateFlags.faceMob !== undefined) { - const mob = updateFlags.faceMob; - - if(mob === null) { - // Reset faced mob - updateMaskData.writeUnsignedShortLE(65535); - } else { - let mobIndex = mob.worldIndex; - - if(mob instanceof Player) { - // Client checks if index is less than 32768. - // If it is, it looks for an NPC. - // If it isn't, it looks for a player (subtracting 32768 to find the index). - mobIndex += 32768 + 1; - } - - updateMaskData.writeUnsignedShortLE(mobIndex); - } - } - - if(updateFlags.chatMessages.length !== 0) { - const message = updateFlags.chatMessages[0]; - - if(message.message) { - updateMaskData.writeString(message.message); - } else { - updateMaskData.writeString('Undefined Message'); - } - } - - if(updateFlags.facePosition) { - const position = updateFlags.facePosition; - updateMaskData.writeOffsetShortLE(position.x * 2 + 1); - updateMaskData.writeShortLE(position.y * 2 + 1); - } - - if(updateFlags.animation) { - const animation = updateFlags.animation; - - if(animation === null || animation.id === -1) { - // Reset animation - updateMaskData.writeShortBE(-1); - updateMaskData.writeNegativeOffsetByte(0); - } else { - const delay = updateFlags.animation.delay || 0; - updateMaskData.writeShortBE(animation.id); - updateMaskData.writeNegativeOffsetByte(delay); - } - } - } - -} diff --git a/src/world/mob/player/updating/player-update-task.ts b/src/world/mob/player/updating/player-update-task.ts deleted file mode 100644 index ee144e872..000000000 --- a/src/world/mob/player/updating/player-update-task.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { Player } from '../player'; -import { RsBuffer, stringToLong } from '@server/net/rs-buffer'; -import { Task } from '@server/task/task'; -import { UpdateFlags } from '@server/world/mob/update-flags'; -import { Packet, PacketType } from '@server/net/packet'; -import { world } from '@server/game-server'; -import { EquipmentSlot, HelmetType, ItemDetails, TorsoType } from '@server/world/config/item-data'; -import { ItemContainer } from '@server/world/items/item-container'; -import { appendMovement, updateTrackedMobs, registerNewMobs } from './mob-updating'; - -/** - * Handles the chonky player updating packet. - */ -export class PlayerUpdateTask extends Task { - - private readonly player: Player; - - public constructor(player: Player) { - super(); - this.player = player; - } - - public async execute(): Promise { - return new Promise(resolve => { - const updateFlags: UpdateFlags = this.player.updateFlags; - const playerUpdatePacket: Packet = new Packet(90, PacketType.DYNAMIC_LARGE, 16); - const currentMapChunk = world.chunkManager.getChunkForWorldPosition(this.player.position); - playerUpdatePacket.openBitChannel(); - - const updateMaskData = RsBuffer.create(); - - if(updateFlags.mapRegionUpdateRequired) { - playerUpdatePacket.writeBits(1, 1); // Update Required - playerUpdatePacket.writeBits(2, 3); // Map Region changed - playerUpdatePacket.writeBits(1, this.player.metadata['teleporting'] ? 1 : 0); // Whether or not the client should discard the current walking queue (1 if teleporting, 0 if not) - playerUpdatePacket.writeBits(2, this.player.position.level); // Player Height - playerUpdatePacket.writeBits(7, this.player.position.chunkLocalY); // Player Local Chunk Y - playerUpdatePacket.writeBits(7, this.player.position.chunkLocalX); // Player Local Chunk X - playerUpdatePacket.writeBits(1, updateFlags.updateBlockRequired ? 1 : 0); // Whether or not an update flag block follows - } else { - appendMovement(this.player, playerUpdatePacket); - } - - this.appendUpdateMaskData(this.player, updateMaskData, false, true); - - //const nearbyPlayers = world.chunkManager.getSurroundingChunks(currentMapChunk).map(chunk => chunk.players).flat(); - let nearbyPlayers = world.playerTree.colliding({ - x: this.player.position.x - 15, - y: this.player.position.y - 15, - width: 32, - height: 32 - }); - - if(nearbyPlayers.length > 200) { - nearbyPlayers = world.playerTree.colliding({ - x: this.player.position.x - 7, - y: this.player.position.y - 7, - width: 16, - height: 16 - }); - } - - this.player.trackedPlayers = updateTrackedMobs(playerUpdatePacket, this.player.position, - mob => this.appendUpdateMaskData(mob as Player, updateMaskData), this.player.trackedPlayers, nearbyPlayers) as Player[]; - - registerNewMobs(playerUpdatePacket, this.player, this.player.trackedPlayers, nearbyPlayers, mob => { - const newPlayer = mob as Player; - const positionOffsetX = newPlayer.position.x - this.player.position.x; - const positionOffsetY = newPlayer.position.y - this.player.position.y; - - // Add other player to this player's list of tracked players - this.player.trackedPlayers.push(newPlayer); - - // Notify the client of the new player and their worldIndex - playerUpdatePacket.writeBits(11, newPlayer.worldIndex + 1); - - playerUpdatePacket.writeBits(5, positionOffsetX); // World Position X axis offset relative to the main player - playerUpdatePacket.writeBits(1, 1); // Update is required - playerUpdatePacket.writeBits(1, 1); // Discard client walking queues - playerUpdatePacket.writeBits(5, positionOffsetY); // World Position Y axis offset relative to the main player - - this.appendUpdateMaskData(newPlayer, updateMaskData, true); - }); - - if(updateMaskData.getWriterIndex() !== 0) { - playerUpdatePacket.writeBits(11, 2047); - playerUpdatePacket.closeBitChannel(); - - playerUpdatePacket.writeBytes(updateMaskData); - } else { - // No player updates were appended, so just end the packet here - playerUpdatePacket.closeBitChannel(); - } - - new Promise(resolve => { - this.player.packetSender.send(playerUpdatePacket); - resolve(); - }); - - resolve(); - }); - } - - private appendUpdateMaskData(player: Player, updateMaskData: RsBuffer, forceUpdate?: boolean, currentPlayer?: boolean): void { - const updateFlags = player.updateFlags; - - if(!updateFlags.updateBlockRequired && !forceUpdate) { - return; - } - - let mask: number = 0; - - if(updateFlags.appearanceUpdateRequired || forceUpdate) { - mask |= 0x4; - } - if(updateFlags.faceMob !== undefined) { - mask |= 0x1; - } - if(updateFlags.facePosition || forceUpdate) { - mask |= 0x2; - } - if(updateFlags.chatMessages.length !== 0 && !currentPlayer) { - mask |= 0x40; - } - if(updateFlags.graphics) { - mask |= 0x200; - } - if(updateFlags.animation !== undefined) { - mask |= 0x8; - } - - if(mask >= 0xff) { - mask |= 0x20; - updateMaskData.writeByte(mask & 0xff); - updateMaskData.writeByte(mask >> 8); - } else { - updateMaskData.writeByte(mask); - } - - if(updateFlags.animation !== undefined) { - const animation = updateFlags.animation; - - if(animation === null || animation.id === -1) { - // Reset animation - updateMaskData.writeShortBE(-1); - updateMaskData.writeNegativeOffsetByte(0); - } else { - const delay = updateFlags.animation.delay || 0; - updateMaskData.writeShortBE(updateFlags.animation.id); - updateMaskData.writeNegativeOffsetByte(delay); - } - } - - if(updateFlags.chatMessages.length !== 0 && !currentPlayer) { - const message = updateFlags.chatMessages[0]; - updateMaskData.writeUnsignedShortBE(((message.color & 0xFF) << 8) + (message.effects & 0xFF)); - updateMaskData.writeByteInverted(player.rights.valueOf()); - updateMaskData.writeOffsetByte(message.data.length); - for(let i = 0; i < message.data.length; i++) { - updateMaskData.writeOffsetByte(message.data.readInt8(i)); - } - } - - if(updateFlags.faceMob !== undefined) { - const mob = updateFlags.faceMob; - - if(mob === null) { - // Reset faced mob - updateMaskData.writeOffsetShortBE(65535); - } else { - let mobIndex = mob.worldIndex; - - if(mob instanceof Player) { - // Client checks if index is less than 32768. - // If it is, it looks for an NPC. - // If it isn't, it looks for a player (subtracting 32768 to find the index). - mobIndex += 32768 + 1; - } - - updateMaskData.writeOffsetShortBE(mobIndex); - } - } - - if(updateFlags.facePosition || forceUpdate) { - if(forceUpdate) { - const position = player.position.fromDirection(player.faceDirection); - updateMaskData.writeShortBE(position.x * 2 + 1); - updateMaskData.writeShortBE(position.y * 2 + 1); - } else { - const position = updateFlags.facePosition; - updateMaskData.writeShortBE(position.x * 2 + 1); - updateMaskData.writeShortBE(position.y * 2 + 1); - } - } - - if(updateFlags.graphics) { - const delay = updateFlags.graphics.delay || 0; - updateMaskData.writeOffsetShortBE(updateFlags.graphics.id); - updateMaskData.writeIntME1(updateFlags.graphics.height << 16 | delay & 0xffff); - } - - if(updateFlags.appearanceUpdateRequired || forceUpdate) { - const equipment = player.equipment; - const appearanceData: RsBuffer = RsBuffer.create(); - appearanceData.writeByte(player.appearance.gender); // Gender - appearanceData.writeByte(-1); // Skull Icon - appearanceData.writeByte(-1); // Prayer Icon - - for(let i = 0; i < 4; i++) { - const item = equipment.items[i]; - - if(item) { - appearanceData.writeShortBE(0x200 + item.itemId); - } else { - appearanceData.writeByte(0); - } - } - - const torsoItem = equipment.items[EquipmentSlot.TORSO]; - let torsoItemData: ItemDetails = null; - if(torsoItem) { - torsoItemData = world.itemData.get(torsoItem.itemId); - appearanceData.writeShortBE(0x200 + torsoItem.itemId); - } else { - appearanceData.writeShortBE(0x100 + player.appearance.torso); - } - - const offHandItem = equipment.items[EquipmentSlot.OFF_HAND]; - if(offHandItem) { - appearanceData.writeShortBE(0x200 + offHandItem.itemId); - } else { - appearanceData.writeByte(0); - } - - if(torsoItemData && torsoItemData.equipment && torsoItemData.equipment.torsoType && torsoItemData.equipment.torsoType === TorsoType.FULL) { - appearanceData.writeShortBE(0x200 + torsoItem.itemId); - } else { - appearanceData.writeShortBE(0x100 + player.appearance.arms); - } - - this.appendBasicAppearanceItem(appearanceData, equipment, player.appearance.legs, EquipmentSlot.LEGS); - - const headItem = equipment.items[EquipmentSlot.HEAD]; - let helmetType = null; - let fullHelmet = false; - - if(headItem) { - const headItemData = world.itemData.get(equipment.items[EquipmentSlot.HEAD].itemId); - - if(headItemData && headItemData.equipment && headItemData.equipment.helmetType) { - helmetType = headItemData.equipment.helmetType; - - if(helmetType === HelmetType.FULL_HELMET) { - fullHelmet = true; - } - } - } - - if(!helmetType || helmetType === HelmetType.HAT) { - appearanceData.writeShortBE(0x100 + player.appearance.head); - } else { - appearanceData.writeByte(0); - } - - this.appendBasicAppearanceItem(appearanceData, equipment, player.appearance.hands, EquipmentSlot.GLOVES); - this.appendBasicAppearanceItem(appearanceData, equipment, player.appearance.feet, EquipmentSlot.BOOTS); - - if(player.appearance.gender === 1 || fullHelmet) { - appearanceData.writeByte(0); - } else { - appearanceData.writeShortBE(0x100 + player.appearance.facialHair); - } - - [ - player.appearance.hairColor, - player.appearance.torsoColor, - player.appearance.legColor, - player.appearance.feetColor, - player.appearance.skinColor, - ].forEach(color => appearanceData.writeByte(color)); - - [ - 0x328, // stand - 0x337, // stand turn - 0x333, // walk - 0x334, // turn 180 - 0x335, // turn 90 - 0x336, // turn 90 reverse - 0x338, // run - ].forEach(animationId => appearanceData.writeShortBE(animationId)); - - appearanceData.writeLongBE(stringToLong(player.username)); // Username - appearanceData.writeByte(3); // Combat Level - appearanceData.writeShortBE(0); // Skill Level (Total Level) - - const appearanceDataSize = appearanceData.getWriterIndex(); - - updateMaskData.writeByte(appearanceDataSize); - updateMaskData.writeBytes(appearanceData.getData().reverse()); - } - } - - private appendBasicAppearanceItem(buffer: RsBuffer, equipment: ItemContainer, appearanceInfo: number, equipmentSlot: EquipmentSlot): void { - const item = equipment.items[equipmentSlot]; - if(item) { - buffer.writeShortBE(0x200 + item.itemId); - } else { - buffer.writeShortBE(0x100 + appearanceInfo); - } - } - -} diff --git a/src/world/mob/player/widget.ts b/src/world/mob/player/widget.ts deleted file mode 100644 index 2742f7118..000000000 --- a/src/world/mob/player/widget.ts +++ /dev/null @@ -1,38 +0,0 @@ -export const widgetIds = { - characterDesign: 3559, - inventory: 3214, - equipment: 1688, - welcomeScreen: 15244, - welcomeScreenChildren: { - question: 17511, - christmas: 15819, - security: 15812, - itemScam: 15801, - passwordSecurity: 15791, - goodBad: 15774, - drama: 15767 - } -}; - -export const widgetSettings = { - runMode: 173, - musicVolume: 168, - soundEffectVolume: 169, - splitPrivateChat: 287, - mouseButtons: 170, - brightness: 166, - chatEffects: 171, - acceptAid: 427, - autoRetaliate: 172, - musicPlayer: 18, - attackStyle: 43 -}; - -export interface ActiveWidget { - widgetId: number; - childWidgetId?: number; - type: 'SCREEN' | 'CHAT' | 'FULLSCREEN'; - disablePlayerMovement?: boolean; - closeOnWalk?: boolean; - forceClosed?: Function; -} diff --git a/src/world/mob/skills.ts b/src/world/mob/skills.ts deleted file mode 100644 index 894e7e601..000000000 --- a/src/world/mob/skills.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Mob } from '@server/world/mob/mob'; -import { Player } from '@server/world/mob/player/player'; -import { dialogueAction } from '@server/world/mob/player/action/dialogue-action'; - -export enum Skill { - ATTACK, - DEFENCE, - STRENGTH, - HITPOINTS, - RANGED, - PRAYER, - MAGIC, - COOKING, - WOODCUTTING, - FLETCHING, - FISHING, - FIREMAKING, - CRAFTING, - SMITHING, - MINING, - HERBLORE, - AGILITY, - THIEVING, - SLAYER, - FARMING, - RUNECRAFTING -} - -export interface SkillDetail { - readonly name: string; - readonly advancementWidgetId?: number; -} - -export const skillDetails: SkillDetail[] = [ - { name: 'Attack' }, - { name: 'Defence' }, - { name: 'Strength' }, - { name: 'Hitpoints' }, - { name: 'Ranged' }, - { name: 'Prayer' }, - { name: 'Magic' }, - { name: 'Cooking' }, - { name: 'Woodcutting', advancementWidgetId: 4272 }, - { name: 'Fletching' }, - { name: 'Fishing' }, - { name: 'Firemaking', advancementWidgetId: 4282 }, - { name: 'Crafting' }, - { name: 'Smithing' }, - { name: 'Mining' }, - { name: 'Herblore' }, - { name: 'Agility' }, - { name: 'Thieving' }, - { name: 'Slayer' }, - { name: 'Farming' }, - { name: 'Runecrafting' } -]; - -export interface SkillValue { - exp: number; - level: number; -} - -export class Skills { - - private _values: SkillValue[]; - - public constructor(private mob: Mob, values?: SkillValue[]) { - if(values) { - this._values = values; - } else { - this._values = this.defaultValues(); - } - } - - private defaultValues(): SkillValue[] { - const values: SkillValue[] = []; - skillDetails.forEach(s => values.push({ exp: 0, level: 1 })); - values[Skill.HITPOINTS] = { exp: 1154, level: 10 }; - return values; - } - - public hasSkillLevel(skillId: number, level: number): boolean { - return this.values[skillId].level >= level; - } - - public getLevelForExp(exp: number): number { - let points = 0; - let output = 0; - - for(let i = 1; i <= 99; i++) { - points += Math.floor(i + 300 * Math.pow(2, i / 7)); - output = Math.floor(points / 4); - if(output >= exp) { - return i; - } - } - - return 99; - } - - public addExp(skillId: number, exp: number): void { - const currentExp = this._values[skillId].exp; - const currentLevel = this.getLevelForExp(currentExp); - let finalExp = currentExp + exp; - if(finalExp > 200000000) { - finalExp = 200000000; - } - - const finalLevel = this.getLevelForExp(finalExp); - - this.setExp(skillId, finalExp); - - if(this.mob instanceof Player) { - this.mob.packetSender.sendSkill(skillId, finalLevel, finalExp); - } - - if(currentLevel !== finalLevel) { - this.setLevel(skillId, finalLevel); - this.mob.playGraphics({ id: 199, delay: 0, height: 125 }); - - if(this.mob instanceof Player) { - const achievementDetails = skillDetails[skillId]; - this.mob.packetSender.chatboxMessage(`Congratulations, you just advanced a ${achievementDetails.name.toLowerCase()} level.`); - - if(achievementDetails.advancementWidgetId) { - dialogueAction(this.mob, { type: 'LEVEL_UP', skillId, lines: [ - `@dbl@Congratulations, you just advanced a ${achievementDetails.name.toLowerCase()} level.`, - `Your ${achievementDetails.name.toLowerCase()} level is now ${finalLevel}.` ] }).then(d => d.close()); - // @TODO sounds - } - } - } - } - - public setExp(skillId: number, exp: number): void { - this._values[skillId].exp = exp; - } - - public setLevel(skillId, level: number): void { - this._values[skillId].level = level; - } - - public get values(): SkillValue[] { - return this._values; - } - - public set values(value: SkillValue[]) { - this._values = value; - } -} diff --git a/src/world/position.ts b/src/world/position.ts index d1a373f38..1f1ef2e5d 100644 --- a/src/world/position.ts +++ b/src/world/position.ts @@ -1,6 +1,6 @@ import { Direction, directionData } from '@server/world/direction'; -import { LandscapeObject } from '@runejs/cache-parser'; -import { gameCache } from '@server/game-server'; +import { LocationObject } from '@runejs/cache-parser'; +import { cache } from '@server/game-server'; const directionDeltaX = [-1, 0, 1, -1, 1, -1, 0, 1]; const directionDeltaY = [1, 1, 1, 0, 0, -1, -1, -1]; @@ -19,10 +19,14 @@ export class Position { this.move(x, y, level); } - public withinInteractionDistance(landscapeObject: LandscapeObject): boolean { - const definition = gameCache.landscapeObjectDefinitions.get(landscapeObject.objectId); - const occupantX = landscapeObject.x; - const occupantY = landscapeObject.y; + public clone(): Position { + return new Position(this.x, this.y, this.level); + } + + public withinInteractionDistance(locationObject: LocationObject): boolean { + const definition = cache.locationObjectDefinitions.get(locationObject.objectId); + const occupantX = locationObject.x; + const occupantY = locationObject.y; let width = definition.sizeX; let height = definition.sizeY; @@ -34,9 +38,9 @@ export class Position { } if(width === 1 && height === 1) { - return this.distanceBetween(new Position(occupantX, occupantY, landscapeObject.level)) <= 1; + return this.distanceBetween(new Position(occupantX, occupantY, locationObject.level)) <= 1; } else { - if(landscapeObject.rotation == 1 || landscapeObject.rotation == 3) { + if(locationObject.orientation == 1 || locationObject.orientation == 3) { const off = width; width = height; height = off; @@ -44,7 +48,7 @@ export class Position { for(let x = occupantX; x < occupantX + width; x++) { for(let y = occupantY; y < occupantY + height; y++) { - if(this.distanceBetween(new Position(x, y, landscapeObject.level)) <= 1) { + if(this.distanceBetween(new Position(x, y, locationObject.level)) <= 1) { return true; } } diff --git a/src/world/world.ts b/src/world/world.ts index 1e6ec3d2d..ed8a1f262 100644 --- a/src/world/world.ts +++ b/src/world/world.ts @@ -1,20 +1,25 @@ -import { Player } from './mob/player/player'; +import { Player } from './actor/player/player'; import { ChunkManager } from './map/chunk-manager'; import { logger } from '@runejs/logger'; import { ItemDetails, parseItemData } from './config/item-data'; -import { gameCache } from '@server/game-server'; +import { cache } from '@server/game-server'; import { Position } from './position'; import { NpcSpawn, parseNpcSpawns } from './config/npc-spawn'; -import { Npc } from './mob/npc/npc'; +import { Npc } from './actor/npc/npc'; import { parseShops, Shop } from '@server/world/config/shops'; import Quadtree from 'quadtree-lib'; import { timer } from 'rxjs'; -import { Mob } from '@server/world/mob/mob'; +import { Actor } from '@server/world/actor/actor'; +import { WorldItem } from '@server/world/items/world-item'; +import { Item } from '@server/world/items/item'; +import { Chunk } from '@server/world/map/chunk'; +import { LocationObject } from '@runejs/cache-parser'; +import { schedule } from '@server/task/task'; export interface QuadtreeKey { x: number; y: number; - mob: Mob; + actor: Actor; } /** @@ -25,6 +30,7 @@ export class World { public static readonly MAX_PLAYERS = 1000; public static readonly MAX_NPCS = 30000; public static readonly TICK_LENGTH = 600; + private readonly debugCycleDuration: boolean = process.argv.indexOf('-tickTime') !== -1; public readonly playerList: Player[] = new Array(World.MAX_PLAYERS).fill(null); public readonly npcList: Npc[] = new Array(World.MAX_NPCS).fill(null); @@ -36,7 +42,7 @@ export class World { public readonly npcTree: Quadtree; public constructor() { - this.itemData = parseItemData(gameCache.itemDefinitions); + this.itemData = parseItemData(cache.itemDefinitions); this.npcSpawns = parseNpcSpawns(); this.shops = parseShops(); this.playerTree = new Quadtree({ @@ -52,13 +58,340 @@ export class World { } public init(): void { - this.chunkManager.generateCollisionMaps(); - this.spawnNpcs(); + new Promise(resolve => { + this.chunkManager.generateCollisionMaps(); + resolve(); + }).then(() => { + this.spawnNpcs(); + }); + } + + /** + * Players a sound at a specific position for all players within range of that position. + * @param position The position to play the sound at. + * @param soundId The ID of the sound effect. + * @param volume The volume the sound should play at. + * @param distance The distance which the sound should reach. + */ + public playLocationSound(position: Position, soundId: number, volume: number, distance: number = 10): void { + this.findNearbyPlayers(position, distance).forEach(player => { + player.outgoingPackets.updateReferencePosition(position); + player.outgoingPackets.playSoundAtPosition( + soundId, + position.x, + position.y, + volume + ); + }); + } + + /** + * Removes a world item from the world. + * @param worldItem The WorldItem object to spawn remove. + */ + public removeWorldItem(worldItem: WorldItem): void { + const chunk = this.chunkManager.getChunkForWorldPosition(worldItem.position); + chunk.removeWorldItem(worldItem); + worldItem.removed = true; + this.deleteWorldItemForPlayers(worldItem, chunk); + } + + /** + * Spawns a world item into the world at the specified position. + * @param item The Item object to spawn as a world item. + * @param position The position to spawn the world item. + * @param initiallyVisibleTo [optional] Who this world item is initially visible to. If not provided, it will be + * initially visible to all players. + * @param expires [optional] The amount of game ticks/cycles before the world item will be automatically deleted + * from the world. If not provided, it will remain within the game world forever. + */ + public spawnWorldItem(item: Item, position: Position, initiallyVisibleTo?: Player, expires?: number): WorldItem { + const chunk = this.chunkManager.getChunkForWorldPosition(position); + const worldItem: WorldItem = { + itemId: item.itemId, + amount: item.amount, + position, + initiallyVisibleTo, + expires + }; + + chunk.addWorldItem(worldItem); + + if(initiallyVisibleTo) { + // If this world item is only visible to one player initially, we setup a timeout to spawn it for all other + // players after 100 game cycles. + initiallyVisibleTo.outgoingPackets.setWorldItem(worldItem, worldItem.position); + setTimeout(() => { + if(worldItem.removed) { + return; + } + + this.spawnWorldItemForPlayers(worldItem, chunk, initiallyVisibleTo); + worldItem.initiallyVisibleTo = undefined; + }, 100 * World.TICK_LENGTH); + } else { + this.spawnWorldItemForPlayers(worldItem, chunk); + } + + if(expires) { + // If the world item is set to expire, set up a timeout to remove it from the game world after the + // specified number of game cycles. + setTimeout(() => { + if(worldItem.removed) { + return; + } + + this.removeWorldItem(worldItem); + }, expires * World.TICK_LENGTH); + } + + return worldItem; + } + + /** + * Spawns the specified world item for players around the specified chunk. + * @param worldItem The WorldItem object to spawn. + * @param chunk The main central chunk that the WorldItem will spawn in. + * @param excludePlayer [optional] A player to be excluded from the world item spawn. + */ + private async spawnWorldItemForPlayers(worldItem: WorldItem, chunk: Chunk, excludePlayer?: Player): Promise { + return new Promise(resolve => { + const nearbyPlayers = this.chunkManager.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); + + nearbyPlayers.forEach(player => { + if(excludePlayer && excludePlayer.equals(player)) { + return; + } + + player.outgoingPackets.setWorldItem(worldItem, worldItem.position); + }); + + resolve(); + }); + } + + /** + * De-spawns the specified world item for players around the specified chunk. + * @param worldItem The WorldItem object to de-spawn. + * @param chunk The main central chunk that the WorldItem will de-spawn from. + */ + private async deleteWorldItemForPlayers(worldItem: WorldItem, chunk: Chunk): Promise { + return new Promise(resolve => { + const nearbyPlayers = this.chunkManager.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); + + nearbyPlayers.forEach(player => { + player.outgoingPackets.removeWorldItem(worldItem, worldItem.position); + }); + + resolve(); + }); + } + + /** + * Replaces a location object within the world with a different object of the same object type, orientation, and position. + * NOT to be confused with `toggleObjects`, which removes one object and adds a different one that may have a differing + * type, orientation, or position (such as a door being opened). + * @param newObject The new location object to spawn, or the id of the location object to spawn. + * @param oldObject The location object being replaced. Usually a game-cache-stored object. + * @param respawnTicks [optional] How many ticks it will take before the original location object respawns. + * If not provided, the original location object will never re-spawn and the new location object will forever + * remain in it's place. + */ + public replaceLocationObject(newObject: LocationObject | number, oldObject: LocationObject, respawnTicks: number = -1): void { + if(typeof newObject === 'number') { + newObject = { + objectId: newObject, + x: oldObject.x, + y: oldObject.y, + level: oldObject.level, + type: oldObject.type, + orientation: oldObject.orientation + } as LocationObject; + } + + const position = new Position(newObject.x, newObject.y, newObject.level); + + this.addLocationObject(newObject, position); + + if(respawnTicks !== -1) { + schedule(respawnTicks).then(() => this.addLocationObject(oldObject, position)); + } + } + + /** + * Removes one location object and adds another to the game world. The new object may be completely different from + * the one being removed, and in different positions. NOT to be confused with `replaceObject`, which will replace + * and existing object with another object of the same type, orientation, and position. + * @param newObject The location object being spawned. + * @param oldObject The location object being removed. + * @param newPosition The position of the location object being added. + * @param oldPosition The position of the location object being removed. + * @param newChunk The chunk which the location object being added resides in. + * @param oldChunk The chunk which the location object being removed resides in. + * @param newObjectInCache Whether or not the object being added is the original game-cache object. + */ + public toggleLocationObjects(newObject: LocationObject, oldObject: LocationObject, newPosition: Position, oldPosition: Position, + newChunk: Chunk, oldChunk: Chunk, newObjectInCache: boolean): void { + if(newObjectInCache) { + this.deleteRemovedLocationObjectMarker(newObject, newPosition, newChunk); + this.deleteAddedLocationObjectMarker(oldObject, oldPosition, oldChunk); + } + + this.addLocationObject(newObject, newPosition); + this.removeLocationObject(oldObject, oldPosition); + } + + /** + * Deletes the tracked record of a spawned location object within a single game chunk. + * @param object The location object to delete the record of. + * @param position The position which the location object was spawned. + * @param chunk The map chunk which the location object was spawned. + */ + public deleteAddedLocationObjectMarker(object: LocationObject, position: Position, chunk: Chunk): void { + chunk.addedLocationObjects.delete(`${position.x},${position.y},${object.objectId}`); + } + + /** + * Deletes the tracked record of a removed/de-spawned location object within a single game chunk. + * @param object The location object to delete the record of. + * @param position The position which the location object was removed. + * @param chunk The map chunk which the location object was removed. + */ + public deleteRemovedLocationObjectMarker(object: LocationObject, position: Position, chunk: Chunk): void { + chunk.removedLocationObjects.delete(`${position.x},${position.y},${object.objectId}`); + } + + /** + * Spawns a temporary location object within the game world. + * @param object The location object to spawn. + * @param position The position to spawn the object at. + * @param expireTicks The number of game cycles/ticks before the object will de-spawn. + */ + public async addTemporaryLocationObject(object: LocationObject, position: Position, expireTicks: number): Promise { + return new Promise(resolve => { + this.addLocationObject(object, position); + + setTimeout(() => { + this.removeLocationObject(object, position, false) + .then(chunk => this.deleteAddedLocationObjectMarker(object, position, chunk)); + resolve(); + }, expireTicks * World.TICK_LENGTH); + }); + } + + /** + * Temporarily de-spawns a location object from the game world. + * @param object The location object to de-spawn temporarily. + * @param position The position of the location object. + * @param expireTicks The number of game cycles/ticks before the object will re-spawn. + */ + public async removeLocationObjectTemporarily(object: LocationObject, position: Position, expireTicks: number): Promise { + const chunk = this.chunkManager.getChunkForWorldPosition(position); + chunk.removeObject(object, position); + + return new Promise(resolve => { + const nearbyPlayers = this.chunkManager.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); + + nearbyPlayers.forEach(player => { + player.outgoingPackets.removeLocationObject(object, position); + }); + + setTimeout(() => { + this.deleteRemovedLocationObjectMarker(object, position, chunk); + this.addLocationObject(object, position); + resolve(); + }, expireTicks * World.TICK_LENGTH); + }); + } + + /** + * Removes/de-spawns a location object from the game world. + * @param object The location object to de-spawn. + * @param position The position of the location object. + * @param markRemoved [optional] Whether or not to mark the object as removed within it's map chunk. If not provided, + * the object will be marked as removed. + */ + public async removeLocationObject(object: LocationObject, position: Position, markRemoved: boolean = true): Promise { + const chunk = this.chunkManager.getChunkForWorldPosition(position); + chunk.removeObject(object, position, markRemoved); + + return new Promise(resolve => { + const nearbyPlayers = this.chunkManager.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); + + nearbyPlayers.forEach(player => { + player.outgoingPackets.removeLocationObject(object, position); + }); + + resolve(chunk); + }); + } + + /** + * Spawns a new location object within the game world. + * @param object The location object to spawn. + * @param position The position at which to spawn the object. + */ + public async addLocationObject(object: LocationObject, position: Position): Promise { + const chunk = this.chunkManager.getChunkForWorldPosition(position); + chunk.addObject(object, position); + + return new Promise(resolve => { + const nearbyPlayers = this.chunkManager.getSurroundingChunks(chunk).map(chunk => chunk.players).flat(); + + nearbyPlayers.forEach(player => { + player.outgoingPackets.setLocationObject(object, position); + }); + + resolve(); + }); + } + + /** + * Finds all Npcs within the given distance from the given position that have the specified Npc ID. + * @param position The center position to search from. + * @param npcId The ID of the Npcs to find. + * @param distance The maximum distance to search for Npcs. + */ + public findNearbyNpcsById(position: Position, npcId: number, distance: number): Npc[] { + return this.npcTree.colliding({ + x: position.x - (distance / 2), + y: position.y - (distance / 2), + width: distance, + height: distance + }).map(quadree => quadree.actor as Npc).filter(npc => npc.id === npcId); + } + + /** + * Finds all Npcs within the given distance from the given position. + * @param position The center position to search from. + * @param distance The maximum distance to search for Npcs. + */ + public findNearbyNpcs(position: Position, distance: number): Npc[] { + return this.npcTree.colliding({ + x: position.x - (distance / 2), + y: position.y - (distance / 2), + width: distance, + height: distance + }).map(quadree => quadree.actor as Npc); + } + + /** + * Finds all Players within the given distance from the given position. + * @param position The center position to search from. + * @param distance The maximum distance to search for Players. + */ + public findNearbyPlayers(position: Position, distance: number): Player[] { + return this.playerTree.colliding({ + x: position.x - (distance / 2), + y: position.y - (distance / 2), + width: distance, + height: distance + }).map(quadree => quadree.actor as Player); } public spawnNpcs(): void { this.npcSpawns.forEach(npcSpawn => { - const npcDefinition = gameCache.npcDefinitions.get(npcSpawn.npcId); + const npcDefinition = cache.npcDefinitions.get(npcSpawn.npcId); const npc = new Npc(npcSpawn, npcDefinition); this.registerNpc(npc); }); @@ -69,8 +402,8 @@ export class World { } public generateFakePlayers(): void { - let x: number = 3222; - let y: number = 3222; + const x: number = 3222; + const y: number = 3222; let xOffset: number = 0; let yOffset: number = 0; @@ -103,35 +436,28 @@ export class World { public async worldTick(): Promise { const hrStart = Date.now(); const activePlayers: Player[] = this.playerList.filter(player => player !== null); - const activeNpcs: Npc[] = this.npcList.filter(npc => npc !== null); - + if(activePlayers.length === 0) { return Promise.resolve().then(() => { - setTimeout(() => this.worldTick(), World.TICK_LENGTH); + setTimeout(() => this.worldTick(), World.TICK_LENGTH); //TODO: subtract processing time }); } + + const activeNpcs: Npc[] = this.npcList.filter(npc => npc !== null); await Promise.all([ ...activePlayers.map(player => player.tick()), ...activeNpcs.map(npc => npc.tick()) ]); - - const playerUpdateTasks = activePlayers.map(player => player.playerUpdateTask.execute()); - const npcUpdateTasks = activePlayers.map(player => player.npcUpdateTask.execute()); - - await Promise.all([ ...playerUpdateTasks, ...npcUpdateTasks ]); + await Promise.all(activePlayers.map(player => player.update())); await Promise.all([ ...activePlayers.map(player => player.reset()), ...activeNpcs.map(npc => npc.reset()) ]); const hrEnd = Date.now(); - const tickTime = hrEnd - hrStart; - - let tickDelay = World.TICK_LENGTH - tickTime; - if(tickDelay < 0) { - tickDelay = 0; - } + const duration = hrEnd - hrStart; + const delay = Math.max(World.TICK_LENGTH - duration, 0); - if(process.argv.indexOf('-tickTime') !== -1) { - logger.info(`World tick completed in ${tickTime} ms, next tick in ${tickDelay} ms.`); + if(this.debugCycleDuration) { + logger.info(`World tick completed in ${duration} ms, next tick in ${delay} ms.`); } - setTimeout(() => this.worldTick(), tickDelay); + setTimeout(() => this.worldTick(), delay); return Promise.resolve(); } @@ -154,7 +480,6 @@ export class World { player.worldIndex = index; this.playerList[index] = player; - player.init(); return true; } @@ -164,7 +489,7 @@ export class World { public npcExists(npc: Npc): boolean { const foundNpc = this.npcList[npc.worldIndex]; - if(!foundNpc) { + if(!foundNpc || !foundNpc.exists) { return false; } @@ -186,6 +511,7 @@ export class World { } public deregisterNpc(npc: Npc): void { + npc.exists = false; this.npcList[npc.worldIndex] = null; }