diff --git a/.eslintrc.js b/.eslintrc.js index 1d82978ae..aca21e292 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -40,7 +40,7 @@ module.exports = { "object-curly-spacing": [ "error", "always" ], "no-var": "error", "prefer-const": "error", - "indent": [ "error", 4, { + "indent": [ "warn", 4, { "SwitchCase": 1 } ], "@typescript-eslint/no-inferrable-types": 0, diff --git a/data/config/item-spawns/lumbridge/lumbridge.json b/data/config/item-spawns/lumbridge/lumbridge.json new file mode 100644 index 000000000..ae9e08f5f --- /dev/null +++ b/data/config/item-spawns/lumbridge/lumbridge.json @@ -0,0 +1,34 @@ +[ + { + "item": "rs:coins", + "amount": 25, + "spawn_x": 3211, + "spawn_y": 3240, + "instance": "global", + "respawn": 25, + "metadata": { + "hello": "world" + } + }, + { + "item": "rs:egg", + "spawn_x": 3191, + "spawn_y": 3276 + }, + { + "item": "rs:egg", + "spawn_x": 3226, + "spawn_y": 3300 + }, + { + "item": "rs:egg", + "spawn_x": 3228, + "spawn_y": 3299 + }, + { + "item": "rs:egg", + "spawn_x": 3231, + "spawn_y": 3301, + "instance": "player" + } +] diff --git a/data/config/items/skills/baking.json b/data/config/items/skills/baking.json index b30ecebd2..888ce211e 100644 --- a/data/config/items/skills/baking.json +++ b/data/config/items/skills/baking.json @@ -4,5 +4,11 @@ "examine": "Useful for baking cakes.", "tradable": true, "weight": 0.1 + }, + "rs:egg": { + "game_id": 1944, + "examine": "A nice fresh egg.", + "tradable": true, + "weight": 0.02 } } diff --git a/src/config/index.ts b/src/config/index.ts index 201f3b3d7..f6da4f1f6 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -18,6 +18,7 @@ import { import { loadNpcSpawnConfigurations, NpcSpawn } from '@server/config/npc-spawn-config'; import { loadShopConfigurations, Shop } from '@server/config/shop-config'; import { Quest } from '@server/world/actor/player/quest'; +import { ItemSpawn, loadItemSpawnConfigurations } from '@server/config/item-spawn-config'; require('json5/lib/register'); export async function loadConfigurationFiles(configurationDir: string): Promise { @@ -47,11 +48,15 @@ export let npcMap: { [key: string]: NpcDetails }; export let npcIdMap: { [key: number]: string }; export let npcPresetMap: NpcPresetConfiguration; export let npcSpawns: NpcSpawn[] = []; +export let itemSpawns: ItemSpawn[] = []; export let shopMap: { [key: string]: Shop }; + export const widgets: { [key: string]: any } = require('../../data/config/widgets.json5'); export async function loadConfigurations(): Promise { + logger.info(`Loading server configurations...`); + const { items, itemIds, itemPresets } = await loadItemConfigurations('data/config/items'); itemMap = items; itemIdMap = itemIds; @@ -63,8 +68,12 @@ export async function loadConfigurations(): Promise { npcPresetMap = npcPresets; npcSpawns = await loadNpcSpawnConfigurations('data/config/npc-spawns'); + itemSpawns = await loadItemSpawnConfigurations('data/config/item-spawns'); shopMap = await loadShopConfigurations('data/config/shops'); + + logger.info(`Loaded ${Object.keys(itemMap).length} items, ${itemSpawns.length} item spawns, ` + + `${Object.keys(npcMap).length} npcs, ${npcSpawns.length} npc spawns, and ${Object.keys(shopMap).length} shops.`); } diff --git a/src/config/item-spawn-config.ts b/src/config/item-spawn-config.ts new file mode 100644 index 000000000..be4fb3258 --- /dev/null +++ b/src/config/item-spawn-config.ts @@ -0,0 +1,57 @@ +import { Position } from '@server/world/position'; +import { loadConfigurationFiles } from '@server/config/index'; + + +export interface ItemSpawnConfiguration { + item: string; + amount?: number; + spawn_x: number; + spawn_y: number; + spawn_level?: number; + instance?: 'global' | 'player'; + respawn?: number; + metadata?: { [key: string]: unknown }; +} + +export class ItemSpawn { + + public itemKey: string; + public amount: number = 1; + public spawnPosition: Position; + public instance: 'global' | 'player' = 'global'; + public respawn: number = 30; + public metadata: { [key: string]: unknown } = {}; + + public constructor(itemKey: string, position: Position) { + this.itemKey = itemKey; + this.spawnPosition = position; + } + +} + +export function translateItemSpawnConfig(config: ItemSpawnConfiguration): ItemSpawn { + const spawn = new ItemSpawn(config.item, new Position(config.spawn_x, config.spawn_y, config.spawn_level || 0)); + if(config.amount !== undefined) { + spawn.amount = config.amount; + } + if(config.instance !== undefined) { + spawn.instance = config.instance; + } + if(config.respawn !== undefined) { + spawn.respawn = config.respawn; + } + if(config.metadata !== undefined) { + spawn.metadata = config.metadata; + } + + return spawn; +} + +export async function loadItemSpawnConfigurations(path: string): Promise { + const itemSpawns = []; + + const files = await loadConfigurationFiles(path); + files.forEach(spawns => spawns.forEach(itemSpawn => itemSpawns.push(translateItemSpawnConfig(itemSpawn)))); + + return itemSpawns; +} diff --git a/src/config/npc-spawn-config.ts b/src/config/npc-spawn-config.ts index 4b3c5c4cc..35dedfc86 100644 --- a/src/config/npc-spawn-config.ts +++ b/src/config/npc-spawn-config.ts @@ -14,10 +14,10 @@ export interface NpcSpawnConfiguration { export class NpcSpawn { - readonly npcKey: string; - readonly spawnPosition: Position; - readonly movementRadius: number; - readonly faceDirection: Direction; + public npcKey: string; + public spawnPosition: Position; + public movementRadius: number; + public faceDirection: Direction; public constructor(npcKey: string, spawnPosition: Position, movementRadius: number = 0, faceDirection: Direction = 'WEST') { diff --git a/src/net/inbound-packets/pickup-item-packet.js b/src/net/inbound-packets/pickup-item-packet.js index e009edea9..31b75f75f 100644 --- a/src/net/inbound-packets/pickup-item-packet.js +++ b/src/net/inbound-packets/pickup-item-packet.js @@ -12,7 +12,14 @@ const pickupItemPacket = (player, packet) => { const worldMods = player.instance.getInstancedChunk(worldItemPosition); const worldItems = worldMods?.mods?.get(worldItemPosition.key)?.worldItems || []; - const worldItem = worldItems.find(i => i.itemId === itemId) || null; + + let worldItem = worldItems.find(i => i.itemId === itemId) || null; + + if(!worldItem) { + const personalMods = player.personalInstance.getInstancedChunk(worldItemPosition); + const personalItems = personalMods?.mods?.get(worldItemPosition.key)?.worldItems || []; + worldItem = personalItems.find(i => i.itemId === itemId) || null; + } if(worldItem && !worldItem.removed) { if(worldItem.owner && !worldItem.owner.equals(player)) { diff --git a/src/plugins/combat/combat-styles.ts b/src/plugins/combat/combat-styles.ts index 2e14f1b74..d9dbad4ef 100644 --- a/src/plugins/combat/combat-styles.ts +++ b/src/plugins/combat/combat-styles.ts @@ -12,8 +12,10 @@ export function updateCombatStyle(player: Player, weaponStyle: WeaponStyle, styl player.savedMetadata.combatStyle = [ weaponStyle, styleIndex ]; player.settings.attackStyle = styleIndex; - const buttonId = combatStyles[weaponStyle][styleIndex].button_id; - player.outgoingPackets.updateClientConfig(widgetScripts.attackStyle, buttonId); + const buttonId = combatStyles[weaponStyle][styleIndex]?.button_id; + if(buttonId !== undefined) { + player.outgoingPackets.updateClientConfig(widgetScripts.attackStyle, buttonId); + } } export function showUnarmed(player: Player): void { diff --git a/src/plugins/combat/combat.ts b/src/plugins/combat/combat.ts index 80fee63cc..fd33ef4b2 100644 --- a/src/plugins/combat/combat.ts +++ b/src/plugins/combat/combat.ts @@ -1,7 +1,7 @@ import { npcAction } from '@server/world/action/npc-action'; import { Actor } from '@server/world/actor/actor'; import { Player } from '@server/world/actor/player/player'; -import { timer } from 'rxjs'; +import { lastValueFrom, timer } from 'rxjs'; import { World } from '@server/world'; import { filter, take } from 'rxjs/operators'; import { animationIds } from '@server/world/config/animation-ids'; @@ -28,8 +28,8 @@ class Combat { public cancelCombat(): void { this.contactInitiated = false; this.combatActive = false; - this.assailant.actionsCancelled.next(); - this.victim.actionsCancelled.next(); + this.assailant.actionsCancelled.next(null); + this.victim.actionsCancelled.next(null); } public async initiateCombat(): Promise { @@ -59,7 +59,7 @@ class Combat { await this.assailant.tail(this.victim); if(!firstAttack) { - await timer(4 * World.TICK_LENGTH).toPromise(); + await lastValueFrom(timer(4 * World.TICK_LENGTH).pipe(take(1))); } if(!this.combatActive) { @@ -155,7 +155,8 @@ class Combat { instance = victim.instance; } - instance.spawnWorldItem(itemIds.bones, deathPosition, this.assailant instanceof Player ? this.assailant : undefined, 300); + instance.spawnWorldItem(itemIds.bones, deathPosition, + { owner: this.assailant instanceof Player ? this.assailant : undefined, expires: 300 }); } // https://forum.tip.it/topic/199687-runescape-formulas-revealed diff --git a/src/plugins/items/drop-item-plugin.ts b/src/plugins/items/drop-item-plugin.ts index ee5d9dff8..cf79b5184 100644 --- a/src/plugins/items/drop-item-plugin.ts +++ b/src/plugins/items/drop-item-plugin.ts @@ -22,8 +22,8 @@ export const action: itemAction = ({ player, itemId, itemSlot }) => { inventory.remove(itemSlot); player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, itemSlot, null); player.playSound(soundIds.dropItem, 5); - player.instance.spawnWorldItem(item, player.position, player, 300); - player.actionsCancelled.next(); + player.instance.spawnWorldItem(item, player.position, { owner: player, expires: 300 }); + player.actionsCancelled.next(null); }; export default { diff --git a/src/plugins/items/pickup-item-plugin.ts b/src/plugins/items/pickup-item-plugin.ts index 7c21e36ed..0aed695b3 100644 --- a/src/plugins/items/pickup-item-plugin.ts +++ b/src/plugins/items/pickup-item-plugin.ts @@ -3,18 +3,20 @@ import { Item } from '../../world/items/item'; import { soundIds } from '@server/world/config/sound-ids'; import { widgets } from '@server/config'; + export const action: worldItemAction = ({ player, worldItem, itemDetails }) => { const inventory = player.inventory; - let slot = -1; - let amount = worldItem.amount; + const amount = worldItem.amount; + let slot = -1 if(itemDetails.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; + if(existingItem.amount + worldItem.amount >= 2147483647) { + // @TODO create new item stack + return; + } else { slot = existingItemIndex; } } @@ -29,17 +31,17 @@ export const action: worldItemAction = ({ player, worldItem, itemDetails }) => { return; } - player.instance.despawnWorldItem(worldItem); + worldItem.instance.despawnWorldItem(worldItem); const item: Item = { itemId: worldItem.itemId, amount }; - inventory.add(item); - player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, slot, item); + const addedItem = inventory.add(item); + player.outgoingPackets.sendUpdateSingleWidgetItem(widgets.inventory, addedItem.slot, addedItem.item); player.playSound(soundIds.pickupItem, 3); - player.actionsCancelled.next(); + player.actionsCancelled.next(null); }; export default { diff --git a/src/plugins/quests/goblin-diplomacy-tutorial/stage-handler.ts b/src/plugins/quests/goblin-diplomacy-tutorial/stage-handler.ts index 2d64aab4c..98f1f9438 100644 --- a/src/plugins/quests/goblin-diplomacy-tutorial/stage-handler.ts +++ b/src/plugins/quests/goblin-diplomacy-tutorial/stage-handler.ts @@ -5,7 +5,7 @@ import { startTutorial, unlockAvailableTabs } from '@server/plugins/quests/goblin-diplomacy-tutorial/goblin-diplomacy'; -import { schedule } from '@server/task/task'; +import { schedule } from '@server/world/task'; import { world } from '@server/game-server'; import { findNpc } from '@server/config'; import { Cutscene } from '@server/world/actor/player/cutscenes'; diff --git a/src/plugins/rune.js b/src/plugins/rune.js index 83fb692b3..99df2f876 100644 --- a/src/plugins/rune.js +++ b/src/plugins/rune.js @@ -12,5 +12,5 @@ export * from '../world/action'; export * from '../world/actor/update-flags'; export * from '../world/actor/skills'; export * from '../world/actor/player/achievements'; -export * from '../task/task'; +export * from '../world/task'; export { widgets } from '../config'; diff --git a/src/plugins/skills/firemaking-plugin.ts b/src/plugins/skills/firemaking-plugin.ts index 5d4adaf4a..7464f2bc8 100644 --- a/src/plugins/skills/firemaking-plugin.ts +++ b/src/plugins/skills/firemaking-plugin.ts @@ -60,7 +60,7 @@ const lightFire = (player: Player, position: Position, worldItemLog: WorldItem, } player.instance.spawnTemporaryGameObject(fireObject, position, fireDuration()).then(() => { - player.instance.spawnWorldItem({ itemId: itemIds.ashes, amount: 1 }, position, null, 300); + player.instance.spawnWorldItem({ itemId: itemIds.ashes, amount: 1 }, position, { expires: 300 }); }); player.face(position, false); @@ -89,7 +89,7 @@ const action: itemOnItemAction = (details) => { // @TODO check firemaking level player.removeItem(removeFromSlot); - const worldItemLog = player.instance.spawnWorldItem(log, player.position, player, 300); + const worldItemLog = player.instance.spawnWorldItem(log, player.position, { owner: player, expires: 300 }); if(player.metadata['lastFire'] && Date.now() - player.metadata['lastFire'] < 1200 && canChain(skillInfo.requiredLevel, player.skills.firemaking.level)) { diff --git a/src/task/task.ts b/src/task/task.ts deleted file mode 100644 index 35098c712..000000000 --- a/src/task/task.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { of, timer } from 'rxjs'; -import { World } from '@server/world'; -import { delay } from 'rxjs/operators'; - -export abstract class Task { - - public abstract async execute(): Promise; - -} - -export const schedule = async (ticks: number): Promise => { - return timer(ticks * World.TICK_LENGTH).toPromise(); -}; - -export const wait = async (waitLength: number): Promise => { - return of(null).pipe(delay(waitLength)).toPromise(); -}; diff --git a/src/world/actor/player/player.ts b/src/world/actor/player/player.ts index 6dc27497f..0b122b2e7 100644 --- a/src/world/actor/player/player.ts +++ b/src/world/actor/player/player.ts @@ -250,6 +250,7 @@ export class Player extends Actor { resolve(); }); + world.spawnWorldItems(this); this.chunkChanged(playerChunk); this.outgoingPackets.flushQueue(); diff --git a/src/world/actor/player/sync/npc-sync-task.ts b/src/world/actor/player/sync/npc-sync-task.ts index 2ce292ed3..8dc9035eb 100644 --- a/src/world/actor/player/sync/npc-sync-task.ts +++ b/src/world/actor/player/sync/npc-sync-task.ts @@ -1,4 +1,4 @@ -import { Task } from '@server/task/task'; +import { Task } from '@server/world/task'; import { Player } from '../player'; import { Packet, PacketType } from '@server/net/packet'; import { Npc } from '@server/world/actor/npc/npc'; diff --git a/src/world/actor/player/sync/player-sync-task.ts b/src/world/actor/player/sync/player-sync-task.ts index 8c827eb73..868a96625 100644 --- a/src/world/actor/player/sync/player-sync-task.ts +++ b/src/world/actor/player/sync/player-sync-task.ts @@ -1,5 +1,5 @@ import { Player } from '../player'; -import { Task } from '@server/task/task'; +import { Task } from '@server/world/task'; import { UpdateFlags } from '@server/world/actor/update-flags'; import { Packet, PacketType } from '@server/net/packet'; import { world } from '@server/game-server'; diff --git a/src/world/index.ts b/src/world/index.ts index b8fb03095..32d579b10 100644 --- a/src/world/index.ts +++ b/src/world/index.ts @@ -9,14 +9,15 @@ import { Position } from './position'; import { Npc } from './actor/npc/npc'; import TravelLocations from '@server/world/config/travel-locations'; import { Actor } from '@server/world/actor/actor'; -import { schedule } from '@server/task/task'; +import { schedule } from '@server/world/task'; import { parseScenerySpawns } from '@server/world/config/scenery-spawns'; import { loadActions } from '@server/world/action'; -import { findNpc, npcSpawns } from '@server/config'; +import { findItem, findNpc, itemSpawns, npcSpawns } from '@server/config'; import { NpcDetails } from '@server/config/npc-config'; import { WorldInstance } from '@server/world/instances'; import { Direction } from '@server/world/direction'; import { NpcSpawn } from '@server/config/npc-spawn-config'; +import { ItemSpawn } from '@server/config/item-spawn-config'; export interface QuadtreeKey { @@ -64,6 +65,7 @@ export class World { await loadPlugins(); await loadActions(); this.spawnGlobalNpcs(); + this.spawnWorldItems(); this.spawnScenery(); } @@ -218,7 +220,8 @@ export class World { height: distance }) .map(quadree => quadree.actor as Player) - .filter(player => player.instance.instanceId === instanceId); + .filter(player => player.personalInstance.instanceId === instanceId || + player.instance.instanceId === instanceId); } /** @@ -230,6 +233,26 @@ export class World { return this.playerList.find(p => p && p.username.toLowerCase() === username); } + /** + * Spawns the list of pre-configured items into either the global instance or a player's personal instance. + * @param player [optional] The player to load the instanced items for. Uses the global world instance if not provided. + */ + public spawnWorldItems(player?: Player): void { + const instance = player ? player.personalInstance : this.globalInstance; + + itemSpawns.filter(spawn => player ? spawn.instance === 'player' : spawn.instance === 'global') + .forEach(itemSpawn => { + const itemDetails = findItem(itemSpawn.itemKey); + if(itemDetails && itemDetails.gameId !== undefined) { + instance.spawnWorldItem( + { itemId: itemDetails.gameId, amount: itemSpawn.amount }, + itemSpawn.spawnPosition, { respawns: itemSpawn.respawn, owner: player || undefined }); + } else { + logger.error(`Item ${itemSpawn.itemKey} can not be spawned; it has not yet been registered on the server.`); + } + }); + } + public spawnGlobalNpcs(): void { npcSpawns.forEach(npcSpawn => { const npcDetails = findNpc(npcSpawn.npcKey) || null; diff --git a/src/world/instances.ts b/src/world/instances.ts index 0846ef6ee..e981eb24f 100644 --- a/src/world/instances.ts +++ b/src/world/instances.ts @@ -5,10 +5,31 @@ import { WorldItem } from '@server/world/items/world-item'; import { Item } from '@server/world/items/item'; import { Player } from '@server/world/actor/player/player'; import { World } from '@server/world/index'; -import { schedule } from '@server/task/task'; +import { schedule } from '@server/world/task'; import { CollisionMap } from '@server/world/map/collision-map'; +/** + * Additional configuration info for an item being spawned in an instance. + */ +interface ItemSpawnConfig { + + /** + * optional] The original owner of the spawned item. + */ + owner?: Player; + + /** + * optional] When the spawned item should expire and de-spawn. + */ + expires?: number; + + /** + * [optional] When the item should re-spawn after being picked up. + */ + respawns?: number; +} + /** * A game world chunk that is tied to a specific instance. */ @@ -82,11 +103,12 @@ export class WorldInstance { * Spawns a new world item in this instance. * @param item The item to spawn into the game world. * @param position The position to spawn the item at. - * @param owner [optional] The original owner of the item, if there is one. - * @param expires [optional] When the world object should expire and de-spawn. + * @param config Additional item spawn config. * If not provided, the item will stay within the instance indefinitely. */ - public spawnWorldItem(item: Item | number, position: Position, owner?: Player, expires?: number): WorldItem { + public spawnWorldItem(item: Item | number, position: Position, config?: ItemSpawnConfig): WorldItem { + const { owner, respawns, expires } = config || {}; + if(typeof item === 'number') { item = { itemId: item, amount: 1 }; } @@ -96,7 +118,8 @@ export class WorldInstance { position, owner, expires, - instanceId: this.instanceId + respawns, + instance: this }; const { chunk: instancedChunk, mods } = this.getTileModifications(position); @@ -162,6 +185,27 @@ export class WorldInstance { worldItem.removed = true; this.worldItemRemoved(worldItem); + + if(worldItem.respawns !== undefined) { + this.respawnItem(worldItem); + } + } + + /** + * Re-spawns a previously de-spawned world item after a specified amount of time. + * @param worldItem The item to re-spawn. + */ + public async respawnItem(worldItem: WorldItem): Promise { + await schedule(worldItem.respawns); + + this.spawnWorldItem({ + itemId: worldItem.itemId, + amount: worldItem.amount + }, worldItem.position, { + respawns: worldItem.respawns, + owner: worldItem.owner, + expires: worldItem.expires + }); } /** @@ -190,9 +234,8 @@ export class WorldInstance { public worldItemRemoved(worldItem: WorldItem): void { const nearbyPlayers = world.findNearbyPlayers(worldItem.position, 16, this.instanceId) || []; - nearbyPlayers.forEach(player => { - player.outgoingPackets.removeWorldItem(worldItem, worldItem.position); - }); + nearbyPlayers.forEach(player => + player.outgoingPackets.removeWorldItem(worldItem, worldItem.position)); } diff --git a/src/world/items/world-item.ts b/src/world/items/world-item.ts index 5263a43ff..58196ea83 100644 --- a/src/world/items/world-item.ts +++ b/src/world/items/world-item.ts @@ -1,5 +1,6 @@ import { Position } from '@server/world/position'; import { Player } from '@server/world/actor/player/player'; +import { WorldInstance } from '@server/world/instances'; export class WorldItem { itemId: number; @@ -7,6 +8,7 @@ export class WorldItem { position: Position; owner?: Player; expires?: number; + respawns?: number; removed?: boolean; - instanceId: string = null; + instance: WorldInstance = null; } diff --git a/src/world/skill-util/harvest-skill.ts b/src/world/skill-util/harvest-skill.ts index 99b700ad6..ac315e5e5 100644 --- a/src/world/skill-util/harvest-skill.ts +++ b/src/world/skill-util/harvest-skill.ts @@ -139,7 +139,8 @@ export function handleHarvesting(details: ObjectActionData, tool: HarvestTool, t if (roll === 1) { randomLoot = true; details.player.sendMessage(colorText('A bird\'s nest falls out of the tree.', colors.red)); - world.globalInstance.spawnWorldItem(rollBirdsNestType(), details.player.position, details.player, 300); + world.globalInstance.spawnWorldItem(rollBirdsNestType(), details.player.position, + { owner: details.player, expires: 300 }); } break; } diff --git a/src/world/task.ts b/src/world/task.ts new file mode 100644 index 000000000..ade1d19bf --- /dev/null +++ b/src/world/task.ts @@ -0,0 +1,17 @@ +import { lastValueFrom, of, timer } from 'rxjs'; +import { World } from '@server/world/index'; +import { delay, take } from 'rxjs/operators'; + +export abstract class Task { + + public abstract async execute(): Promise; + +} + +export const schedule = async (ticks: number): Promise => { + return lastValueFrom(timer(ticks * World.TICK_LENGTH).pipe(take(1))); +}; + +export const wait = async (waitLength: number): Promise => { + return lastValueFrom(timer(waitLength).pipe(take(1))); +};