From e7e790efff577d1164dc66e8bae9b5f065840ea8 Mon Sep 17 00:00:00 2001 From: Casper Warden <216465704+casperwardensl@users.noreply.github.com> Date: Tue, 21 Nov 2023 13:57:06 +0000 Subject: [PATCH] Vastly simplify the object resolution stuff. --- lib/classes/BatchQueue.spec.ts | 227 +++++++++ lib/classes/BatchQueue.ts | 126 +++++ lib/classes/ConcurrentQueue.ts | 47 ++ lib/classes/InventoryItem.ts | 4 +- lib/classes/ObjectResolver.ts | 607 ++++++++----------------- lib/classes/ObjectStoreLite.ts | 10 +- lib/classes/Utils.ts | 11 + lib/classes/commands/RegionCommands.ts | 42 +- lib/classes/public/Avatar.ts | 9 +- lib/classes/public/GameObject.ts | 15 +- package-lock.json | 4 +- package.json | 2 +- 12 files changed, 655 insertions(+), 449 deletions(-) create mode 100644 lib/classes/BatchQueue.spec.ts create mode 100644 lib/classes/BatchQueue.ts create mode 100644 lib/classes/ConcurrentQueue.ts diff --git a/lib/classes/BatchQueue.spec.ts b/lib/classes/BatchQueue.spec.ts new file mode 100644 index 0000000..10435e5 --- /dev/null +++ b/lib/classes/BatchQueue.spec.ts @@ -0,0 +1,227 @@ +import { BatchQueue } from './BatchQueue'; +import * as assert from 'assert'; +import { Utils } from './Utils'; + +export interface TestType +{ + baseNumber: number; + numberToAdd: number; + result?: number; +} + +describe('BatchQueue', () => +{ + it('batches correctly', async() => + { + let maxBatchSize = -1; + let minBatchSize = -1; + const b = new BatchQueue(5, async(items: Set): Promise> => + { + const failures = new Set(); + if (items.size > maxBatchSize || maxBatchSize === -1) + { + maxBatchSize = items.size; + } + if (items.size < minBatchSize || minBatchSize === -1) + { + minBatchSize = items.size; + } + + for (const e of items.values()) + { + e.result = e.baseNumber + e.numberToAdd; + } + + return failures; + }); + const batch1: TestType[] = [ + { + baseNumber: 1, + numberToAdd: 5 + }, + { + baseNumber: 2, + numberToAdd: 6 + }, + { + baseNumber: 3, + numberToAdd: 7 + }, + { + baseNumber: 4, + numberToAdd: 8 + }, + { + baseNumber: 5, + numberToAdd: 9 + }, + { + baseNumber: 6, + numberToAdd: 10 + }, + { + baseNumber: 7, + numberToAdd: 11 + }, + { + baseNumber: 8, + numberToAdd: 12 + }, + { + baseNumber: 9, + numberToAdd: 13 + }, + { + baseNumber: 10, + numberToAdd: 14 + } + ]; + await b.add(batch1); + assert.equal(maxBatchSize, 5); + assert.equal(minBatchSize, 5); + for (const item of batch1) + { + assert.equal(item.result, item.baseNumber + item.numberToAdd); + } + }); + + it('fails correctly on exception', async() => + { + const batch1: TestType[] = [ + { + baseNumber: 50, + numberToAdd: 5 + } + ]; + const batch2: TestType[] = [ + { + baseNumber: 60, + numberToAdd: 5 + } + ]; + const batch3: TestType[] = [ + { + baseNumber: 70, + numberToAdd: 5 + } + ]; + + const b = new BatchQueue(5, async(items: Set): Promise> => + { + await Utils.sleep(100); + const failures = new Set(); + + for (const e of items.values()) + { + if (e.baseNumber === 60) + { + throw new Error('60 is prohibited!'); + } + e.result = e.baseNumber + e.numberToAdd; + } + + return failures; + }); + + const promises: Promise[] = [ + b.add(batch1), + b.add(batch2), + b.add(batch3) + ]; + const result = await Promise.allSettled(promises); + assert.equal(result[0].status, 'fulfilled'); // because the first job will start executing immediately, it won't get failed by the rejection + assert.equal(result[1].status, 'rejected'); + assert.equal(result[2].status, 'rejected'); + }); + + it('returns failed jobs correctly', async() => + { + const batch1: TestType[] = [ + { + baseNumber: 50, + numberToAdd: 5 + }, + { + baseNumber: 70, + numberToAdd: 5 + } + ]; + const batch2: TestType[] = [ + { + baseNumber: 60, + numberToAdd: 5 + }, + { + baseNumber: 50, + numberToAdd: 5 + } + ]; + const batch3: TestType[] = [ + { + baseNumber: 40, + numberToAdd: 5 + }, + { + baseNumber: 50, + numberToAdd: 5 + }, + { + baseNumber: 60, + numberToAdd: 5 + } + ]; + + const b = new BatchQueue(5, async(items: Set): Promise> => + { + await Utils.sleep(100); + const failures = new Set(); + + for (const e of items.values()) + { + if (e.baseNumber === 60) + { + failures.add(e) + } + else + { + e.result = e.baseNumber + e.numberToAdd; + } + } + + return failures; + }); + + const promises: Promise[] = [ + b.add(batch1), + b.add(batch2), + b.add(batch3) + ]; + const result = await Promise.allSettled(promises); + + function isFulfilled(prResult: PromiseSettledResult): prResult is PromiseFulfilledResult + { + return prResult.status === 'fulfilled'; + } + + assert.equal(result[0].status, 'fulfilled'); + assert.equal(result[1].status, 'fulfilled'); + assert.equal(result[2].status, 'fulfilled'); + + if (isFulfilled(result[0])) + { + assert.equal(result[0].value.length, 0); + } + if (isFulfilled(result[1])) + { + assert.equal(result[1].value.length, 1); + assert.equal(result[1].value[0].baseNumber, 60); + } + if (isFulfilled(result[2])) + { + assert.equal(result[2].value.length, 1); + assert.equal(result[2].value[0].baseNumber, 60); + } + + + }); +}); diff --git a/lib/classes/BatchQueue.ts b/lib/classes/BatchQueue.ts new file mode 100644 index 0000000..c8fb226 --- /dev/null +++ b/lib/classes/BatchQueue.ts @@ -0,0 +1,126 @@ +import { Subject } from 'rxjs'; + +export class BatchQueue +{ + private running = false; + private pending: Set = new Set(); + private onResult = new Subject<{ + batch: Set, + failed?: Set, + exception?: unknown + }>(); + + public constructor(private readonly batchSize: number, private readonly func: (items: Set) => Promise>) + { + + } + + public async add(ids: T[]): Promise + { + const waiting = new Set(); + for (const id of ids) + { + waiting.add(id); + this.pending.add(id); + } + + if (!this.running) + { + this.processBatch().catch((_e) => + { + // ignore + }); + } + + return new Promise((resolve, reject) => + { + const failed: T[] = []; + const subs = this.onResult.subscribe((results: { + batch: Set, + failed?: Set, + exception?: unknown + }) => + { + let included = false; + for (const v of results.batch.values()) + { + if (waiting.has(v)) + { + included = true; + if (results.failed?.has(v)) + { + failed.push(v); + } + waiting.delete(v); + } + } + if (!included) + { + return; + } + if (results.exception !== undefined) + { + subs.unsubscribe(); + reject(results.exception); + return; + } + if (waiting.size === 0) + { + subs.unsubscribe(); + resolve(failed); + return; + } + }); + }); + } + + private async processBatch(): Promise + { + if (this.running) + { + return; + } + try + { + this.running = true; + const thisBatch = new Set(); + const values = this.pending.values(); + for (const v of values) + { + thisBatch.add(v); + this.pending.delete(v); + if (thisBatch.size >= this.batchSize) + { + break; + } + } + + try + { + const failedItems = await this.func(thisBatch) + this.onResult.next({ + batch: thisBatch, + failed: failedItems + }); + } + catch (e) + { + this.onResult.next({ + batch: thisBatch, + exception: e + }); + } + } + finally + { + this.running = false; + if (this.pending.size > 0) + { + this.processBatch().catch((_e) => + { + // ignore + }); + } + } + } +} diff --git a/lib/classes/ConcurrentQueue.ts b/lib/classes/ConcurrentQueue.ts new file mode 100644 index 0000000..5dcd953 --- /dev/null +++ b/lib/classes/ConcurrentQueue.ts @@ -0,0 +1,47 @@ +export class ConcurrentQueue +{ + private concurrency: number; + private runningCount; + private jobQueue: { job: () => Promise, resolve: (value: void | PromiseLike) => void, reject: (reason?: unknown) => void }[]; + + constructor(concurrency: number) + { + this.concurrency = concurrency; + this.runningCount = 0; + this.jobQueue = []; + } + + private executeJob(job: () => Promise, resolve: (value: void | PromiseLike) => void, reject: (reason?: unknown) => void): void + { + this.runningCount++; + job().then(resolve).catch(reject).finally(() => + { + this.runningCount--; + this.tryExecuteNext(); + }); + } + + private tryExecuteNext(): void + { + if (this.runningCount < this.concurrency && this.jobQueue.length > 0) + { + const { job, resolve, reject } = this.jobQueue.shift()!; + this.executeJob(job, resolve, reject); + } + } + + public addJob(job: () => Promise): Promise + { + return new Promise((resolve, reject) => + { + if (this.runningCount < this.concurrency) + { + this.executeJob(job, resolve, reject); + } + else + { + this.jobQueue.push({ job, resolve, reject }); + } + }); + } +} diff --git a/lib/classes/InventoryItem.ts b/lib/classes/InventoryItem.ts index 1441538..5b45849 100644 --- a/lib/classes/InventoryItem.ts +++ b/lib/classes/InventoryItem.ts @@ -917,7 +917,7 @@ export class InventoryItem if (evt.createSelected && !evt.object.resolvedAt) { // We need to get the full ObjectProperties so we can be sure this is or isn't a rez from inventory - await agent.currentRegion.clientCommands.region.resolveObject(evt.object, false, true); + await agent.currentRegion.clientCommands.region.resolveObject(evt.object, {}); } if (evt.createSelected && !evt.object.claimedForBuild) { @@ -1031,7 +1031,7 @@ export class InventoryItem if (evt.createSelected && !evt.object.resolvedAt) { // We need to get the full ObjectProperties so we can be sure this is or isn't a rez from inventory - await agent.currentRegion.clientCommands.region.resolveObject(evt.object, false, true); + await agent.currentRegion.clientCommands.region.resolveObject(evt.object, {}); } if (evt.createSelected && !evt.object.claimedForBuild && !claimedPrim) { diff --git a/lib/classes/ObjectResolver.ts b/lib/classes/ObjectResolver.ts index d4c40e8..fab6c34 100644 --- a/lib/classes/ObjectResolver.ts +++ b/lib/classes/ObjectResolver.ts @@ -1,222 +1,86 @@ import { GameObject } from './public/GameObject'; -import { PrimFlags, UUID } from '..'; +import { PCode, PrimFlags, UUID } from '..'; import { Region } from './Region'; -import { IResolveJob } from './interfaces/IResolveJob'; -import { Subject, Subscription } from 'rxjs'; -import { ObjectResolvedEvent } from '../events/ObjectResolvedEvent'; - -import * as LLSD from '@caspertech/llsd'; +import { Subscription } from 'rxjs'; import { GetObjectsOptions } from './commands/RegionCommands'; +import { ObjectResolvedEvent } from '../events/ObjectResolvedEvent'; +import { clearTimeout } from 'timers'; +import { BatchQueue } from './BatchQueue'; export class ObjectResolver { - private objectsInQueue: { [key: number]: IResolveJob } = {}; - - private queue: number[] = []; - - private maxConcurrency = 128; - private currentlyRunning = false; - - private onObjectResolveRan: Subject = new Subject(); + private resolveQueue = new BatchQueue(256, this.resolveInternal.bind(this)); + private getCostsQueue = new BatchQueue(256, this.getCostsInternal.bind(this)); constructor(private region?: Region) { } - resolveObjects(objects: GameObject[], options: GetObjectsOptions): Promise + public async resolveObjects(objects: GameObject[], options: GetObjectsOptions): Promise { - return new Promise((resolve, reject) => + if (!this.region) { - if (!this.region) + throw new Error('Region is going away'); + } + + // First, create a map of all object IDs + const objs = new Map(); + const failed: GameObject[] = []; + for (const obj of objects) + { + if (!options.includeTempObjects && ((obj.Flags ?? 0) & PrimFlags.TemporaryOnRez)) { - reject(new Error('Region is going away')); - return; + continue; } - if (options.outputLog) + if (!options.includeAvatars && obj.PCode === PCode.Avatar) { - // console.log('[RESOLVER] Scanning ' + objects.length + ' objects, skipInventory: ' + skipInventory); + continue; } + this.region.objects.populateChildren(obj); + this.scanObject(obj, objs); + } - // First, create a map of all object IDs - const objs: { [key: number]: GameObject } = {}; - const failed: GameObject[] = []; - for (const obj of objects) - { - this.region.objects.populateChildren(obj); - this.scanObject(obj, objs); - } + if (objs.size === 0) + { + return failed; + } - const queueObject = (id: number) => - { - if (this.objectsInQueue[id] === undefined) - { - this.objectsInQueue[id] = { - object: objs[id], - options - }; - this.queue.push(id); - } - else if (this.objectsInQueue[id].options.skipInventory && !options.skipInventory) - { - this.objectsInQueue[id].options.skipInventory = false; - } - }; - - const skipped: number[] = []; - for (const obj of Object.keys(objs)) - { - const id = parseInt(obj, 10); - const gameObject = objs[id]; - if (options.outputLog === true) - { - // console.log('ResolvedInventory: ' + gameObject.resolvedInventory + ', skip: ' + skipInventory); - } - if (!options.onlyUnresolved || gameObject.resolvedAt === undefined || gameObject.resolvedAt === 0 || (!options.skipInventory && !gameObject.resolvedInventory)) - { - if (!options.onlyUnresolved) - { - gameObject.resolvedAt = 0; - gameObject.resolveAttempts = 0; - } - queueObject(id); - } - else - { - skipped.push(id); - } - } - for (const id of skipped) - { - delete objs[id]; - if (options.outputLog === true) - { - // console.log('[RESOLVER] Skipping already resolved object. ' + amountLeft + ' objects remaining to resolve (' + this.queue.length + ' in queue)'); - } - } - - if (Object.keys(objs).length === 0) - { - resolve(failed); - return; - } - - let objResolve: Subscription | undefined = undefined; - let objProps: Subscription | undefined = undefined; - - const checkObject = (obj: GameObject): boolean => - { - let done = false; - if (obj.resolvedAt !== undefined && obj.resolvedAt > 0) - { - if (options.skipInventory === true || obj.resolvedInventory) - { - if (options.outputLog === true) - { - // console.log('[RESOLVER] Resolved an object. ' + amountLeft + ' objects remaining to resolve (' + this.queue.length + ' in queue)'); - } - done = true; - } - } - if (obj.resolveAttempts > 2) - { - failed.push(obj); - done = true; - } - if (done) - { - delete objs[obj.ID]; - if (Object.keys(objs).length === 0) - { - if (objResolve !== undefined) - { - objResolve.unsubscribe(); - objResolve = undefined; - } - if (objProps !== undefined) - { - objProps.unsubscribe(); - objProps = undefined; - } - resolve(failed); - } - } - return done; - }; - - objResolve = this.onObjectResolveRan.subscribe((obj: GameObject) => - { - if (objs[obj.ID] !== undefined) - { - if (options.outputLog === true) - { - // console.log('Got onObjectResolveRan for 1 object ...'); - } - if (!checkObject(obj)) - { - if (options.outputLog === true) - { - // console.log(' .. Not resolved yet'); - } - setTimeout(() => - { - if (!checkObject(obj)) - { - // Requeue - if (options.outputLog) - { - // console.log(' .. ' + obj.ID + ' still not resolved yet, requeuing'); - } - queueObject(obj.ID); - this.run().then(() => - { - - }).catch((err) => - { - console.error(err); - }); - } - }, 10000); - } - } - }); - - if (!this.region) - { - return; - } - objProps = this.region.clientEvents.onObjectResolvedEvent.subscribe((obj: ObjectResolvedEvent) => - { - if (objs[obj.object.ID] !== undefined) - { - if (options.outputLog) - { - // console.log('Got object resolved event for ' + obj.object.ID); - } - if (!checkObject(obj.object)) - { - // console.log(' ... Still not resolved yet'); - } - - } - }); - - this.run().then(() => - { - - }).catch((err) => - { - console.error(err); - }); - }); + return this.resolveQueue.add(Array.from(objs.values())); } - private scanObject(obj: GameObject, map: { [key: number]: GameObject }): void + public async getInventory(object: GameObject): Promise + { + await this.getInventories([object]); + } + + public async getInventories(objects: GameObject[]): Promise + { + for (const obj of objects) + { + if (!obj.resolvedInventory) + { + await obj.updateInventory(); + } + } + } + + public async getCosts(objects: GameObject[]): Promise + { + await this.getCostsQueue.add(objects); + } + + public shutdown(): void + { + delete this.region; + } + + private scanObject(obj: GameObject, map: Map): void { const localID = obj.ID; - if (!map[localID]) + if (!map.has(localID)) { - map[localID] = obj; + map.set(localID, obj); if (obj.children) { for (const child of obj.children) @@ -227,250 +91,173 @@ export class ObjectResolver } } - private async run(): Promise - { - if (this.currentlyRunning) - { - // console.log('Prodded but already running'); - return; - } - try - { - // console.log('Running. Queue length: ' + this.queue.length); - while (this.queue.length > 0) - { - const jobs = []; - for (let x = 0; x < this.maxConcurrency && this.queue.length > 0; x++) - { - const objectID = this.queue.shift(); - if (objectID !== undefined) - { - jobs.push(this.objectsInQueue[objectID]); - delete this.objectsInQueue[objectID]; - } - } - await this.doResolve(jobs); - } - } - catch (error) - { - console.error(error); - } - finally - { - this.currentlyRunning = false; - } - if (this.queue.length > 0) - { - this.run().then(() => - { - - }, (err) => - { - console.error(err); - }); - } - } - - private async doResolve(jobs: IResolveJob[]): Promise + private async resolveInternal(objs: Set): Promise> { if (!this.region) { - return; + throw new Error('Region went away'); } - const resolveTime = new Date().getTime() / 1000; - const objectList = []; - let totalRemaining = 0; + const objArray = Array.from(objs.values()); try { - for (const job of jobs) - { - if (job.object.resolvedAt === undefined || job.object.resolvedAt < resolveTime) - { - objectList.push(job.object); - totalRemaining++; - } - } - - if (objectList.length > 0) - { - // console.log('Selecting ' + objectList.length + ' objects'); - if (!this.region) - { - return; - } - await this.region.clientCommands.region.selectObjects(objectList); - // console.log('Deselecting ' + objectList.length + ' objects'); - - if (!this.region) - { - return; - } - await this.region.clientCommands.region.deselectObjects(objectList); - for (const chk of objectList) - { - if (chk.resolvedAt !== undefined && chk.resolvedAt >= resolveTime) - { - totalRemaining --; - } - } - } - - for (const job of jobs) - { - if (!this.region) - { - return; - } - if (!job.options.skipInventory && (job.options.includeTempObjects || ((job.object.Flags ?? 0) & PrimFlags.TemporaryOnRez) === 0)) - { - const o = job.object; - if ((o.resolveAttempts === undefined || o.resolveAttempts < 3) && o.FullID !== undefined && o.name !== undefined && o.Flags !== undefined && !(o.Flags & PrimFlags.InventoryEmpty) && (!o.inventory || o.inventory.length === 0)) - { - if (job.options.outputLog) - { - // console.log('Processing inventory for ' + job.object.ID); - } - try - { - await o.updateInventory(); - } - catch (error) - { - if (o.resolveAttempts === undefined) - { - o.resolveAttempts = 0; - } - o.resolveAttempts++; - if (o.FullID !== undefined) - { - console.error('Error downloading task inventory of ' + o.FullID.toString() + ':'); - console.error(error); - } - else - { - console.error('Error downloading task inventory of ' + o.ID + ':'); - console.error(error); - } - } - } - else - { - if (job.options.outputLog) - { - // console.log('Skipping inventory for ' + job.object.ID); - } - } - o.resolvedInventory = true; - } - } - } - catch (ignore) - { - console.error(ignore); + await this.region.clientCommands.region.selectObjects(objArray); } finally { - if (totalRemaining < 1) + await this.region.clientCommands.region.deselectObjects(objArray); + } + + if (!this.region) + { + throw new Error('Region went away'); + } + + const objects = new Map(); + for (const obj of objs.values()) + { + objects.set(obj.ID, obj); + } + + for (let x = 0; x < 3; x++) + { + try { - totalRemaining = 0; - for (const obj of objectList) - { - if (obj.resolvedAt === undefined || obj.resolvedAt < resolveTime) - { - totalRemaining++; - } - } - if (totalRemaining > 0) - { - console.error(totalRemaining + ' objects could not be resolved'); - } + await this.waitForResolve(objects, 10000); } - const that = this; - const getCosts = async function(objIDs: UUID[]): Promise + catch (_e) { + // Ignore + } + } + + const failed = new Set(); + for (const o of objects.values()) + { + failed.add(o); + } + + return failed; + } + + private async getCostsInternal(objs: Set): Promise> + { + const failed = new Set(); + + const submitted: Map = new Map(); + for (const obj of objs.values()) + { + submitted.set(obj.FullID.toString(), obj); + } + + try + { + if (!this.region) + { + return objs; + } + const result = await this.region.caps.capsPostXML('GetObjectCost', { + 'object_ids': Array.from(submitted.keys()) + }); + const uuids = Object.keys(result); + for (const key of uuids) + { + const costs = result[key]; try { - if (!that.region) + if (!this.region) { - return; + continue; } - const result = await that.region.caps.capsPostXML('GetObjectCost', { - 'object_ids': objIDs - }); - const uuids = Object.keys(result); - for (const key of uuids) - { - const costs = result[key]; - try - { - if (!that.region) - { - return; - } - const obj: GameObject = that.region.objects.getObjectByUUID(new UUID(key)); - obj.linkPhysicsImpact = parseFloat(costs['linked_set_physics_cost']); - obj.linkResourceImpact = parseFloat(costs['linked_set_resource_cost']); - obj.physicaImpact = parseFloat(costs['physics_cost']); - obj.resourceImpact = parseFloat(costs['resource_cost']); - obj.limitingType = costs['resource_limiting_type']; + const obj: GameObject = this.region.objects.getObjectByUUID(new UUID(key)); + obj.linkPhysicsImpact = parseFloat(costs['linked_set_physics_cost']); + obj.linkResourceImpact = parseFloat(costs['linked_set_resource_cost']); + obj.physicaImpact = parseFloat(costs['physics_cost']); + obj.resourceImpact = parseFloat(costs['resource_cost']); + obj.limitingType = costs['resource_limiting_type']; - obj.landImpact = Math.round(obj.linkPhysicsImpact); - if (obj.linkResourceImpact > obj.linkPhysicsImpact) - { - obj.landImpact = Math.round(obj.linkResourceImpact); - } - obj.calculatedLandImpact = obj.landImpact; - if (obj.Flags !== undefined && obj.Flags & PrimFlags.TemporaryOnRez && obj.limitingType === 'legacy') - { - obj.calculatedLandImpact = 0; - } - } - catch (error) - { - } + obj.landImpact = Math.round(obj.linkPhysicsImpact); + if (obj.linkResourceImpact > obj.linkPhysicsImpact) + { + obj.landImpact = Math.round(obj.linkResourceImpact); } + obj.calculatedLandImpact = obj.landImpact; + if (obj.Flags !== undefined && obj.Flags & PrimFlags.TemporaryOnRez && obj.limitingType === 'legacy') + { + obj.calculatedLandImpact = 0; + } + submitted.delete(key); } catch (error) { } - }; - - let ids: UUID[] = []; - const promises: Promise[] = []; - for (const job of jobs) - { - if (job.object.landImpact === undefined) - { - ids.push(new LLSD.UUID(job.object.FullID)); - } - if (ids.length > 255) - { - promises.push(getCosts(ids)); - ids = []; - } - } - if (ids.length > 0) - { - promises.push(getCosts(ids)); - } - // console.log('Waiting for all'); - await Promise.all(promises); - for (const job of jobs) - { - if (job.options.outputLog) - { - // console.log('Signalling resolve OK for ' + job.object.ID); - } - this.onObjectResolveRan.next(job.object); } } + catch (error) + { + } + + for (const go of submitted.values()) + { + failed.add(go); + } + + return failed; } - public shutdown(): void + private async waitForResolve(objs: Map, timeout: number = 10000): Promise { - delete this.region; + const entries = objs.entries(); + for (const [localID, entry] of entries) + { + if (entry.resolvedAt !== undefined) + { + objs.delete(localID); + } + } + + if (objs.size === 0) + { + return; + } + + return new Promise((resolve, reject) => + { + if (!this.region) + { + reject(new Error('Region went away')); + return; + } + let subs: Subscription | undefined = undefined; + let timer: number | undefined = undefined; + subs = this.region.clientEvents.onObjectResolvedEvent.subscribe((obj: ObjectResolvedEvent) => + { + objs.delete(obj.object.ID); + if (objs.size === 0) + { + if (timer !== undefined) + { + clearTimeout(timer); + timer = undefined; + } + if (subs !== undefined) + { + subs.unsubscribe(); + subs = undefined; + } + resolve(); + } + }); + timer = setTimeout(() => + { + if (subs !== undefined) + { + subs.unsubscribe(); + subs = undefined; + } + reject(new Error('Timeout')); + }, timeout) as unknown as number; + }); } } diff --git a/lib/classes/ObjectStoreLite.ts b/lib/classes/ObjectStoreLite.ts index 1c66fa6..984c124 100644 --- a/lib/classes/ObjectStoreLite.ts +++ b/lib/classes/ObjectStoreLite.ts @@ -36,12 +36,12 @@ import { Vector3 } from './Vector3'; import { ObjectPhysicsDataEvent } from '../events/ObjectPhysicsDataEvent'; import { ObjectResolvedEvent } from '../events/ObjectResolvedEvent'; import { Avatar } from './public/Avatar'; -import Timer = NodeJS.Timer; import { GenericStreamingMessageMessage } from './messages/GenericStreamingMessage'; import { LLSDNotationParser } from './llsd/LLSDNotationParser'; import { LLSDMap } from './llsd/LLSDMap'; import { LLGLTFMaterialOverride, LLGLTFTextureTransformOverride } from './LLGLTFMaterialOverride'; import * as Long from 'long'; +import Timer = NodeJS.Timer; export class ObjectStoreLite implements IObjectStore { @@ -90,6 +90,7 @@ export class ObjectStoreLite implements IObjectStore private selectedPrimsWithoutUpdate = new Map(); private selectedChecker?: Timer; private blacklist: Map = new Map(); + private pendingResolves: Set = new Set(); rtree?: RBush3D; @@ -739,7 +740,7 @@ export class ObjectStoreLite implements IObjectStore invItemID = new UUID(obj.NameValue['AttachItemID'].value); } - this.agent.currentRegion.clientCommands.region.resolveObject(obj, true, false).then(() => + this.agent.currentRegion.clientCommands.region.resolveObject(obj, {}).then(() => { try { @@ -796,6 +797,11 @@ export class ObjectStoreLite implements IObjectStore } } + public pendingResolve(id: number): void + { + this.pendingResolves.add(id); + } + protected objectUpdateCached(objectUpdateCached: ObjectUpdateCachedMessage): void { if (this.circuit === undefined) diff --git a/lib/classes/Utils.ts b/lib/classes/Utils.ts index 2f20cfa..9a887dc 100644 --- a/lib/classes/Utils.ts +++ b/lib/classes/Utils.ts @@ -989,4 +989,15 @@ export class Utils lineObj.pos += Buffer.byteLength(line) + 1; return line.replace(/\r/, '').trim().replace(/[\t ]+/g, ' '); } + + public static sleep(ms: number): Promise + { + return new Promise((resolve) => + { + setTimeout(() => + { + resolve(); + }, ms) + }); + } } diff --git a/lib/classes/commands/RegionCommands.ts b/lib/classes/commands/RegionCommands.ts index 73a0f4e..a145c99 100644 --- a/lib/classes/commands/RegionCommands.ts +++ b/lib/classes/commands/RegionCommands.ts @@ -23,7 +23,6 @@ import { ObjectPropertiesMessage } from '../messages/ObjectProperties'; import { ObjectSelectMessage } from '../messages/ObjectSelect'; import { RegionHandleRequestMessage } from '../messages/RegionHandleRequest'; import { RegionIDAndHandleReplyMessage } from '../messages/RegionIDAndHandleReply'; -import { ObjectResolver } from '../ObjectResolver'; import { Avatar } from '../public/Avatar'; import { GameObject } from '../public/GameObject'; import { Parcel } from '../public/Parcel'; @@ -39,9 +38,6 @@ import Timer = NodeJS.Timer; export interface GetObjectsOptions { resolve?: boolean; - onlyUnresolved?: boolean; - skipInventory?: boolean; - outputLog?: boolean; includeTempObjects?: boolean; includeAvatars?: boolean; } @@ -382,14 +378,24 @@ export class RegionCommands extends CommandsBase return this.currentRegion.regionName; } - async resolveObject(object: GameObject, forceResolve = false, skipInventory = false): Promise + async resolveObject(object: GameObject, options: GetObjectsOptions): Promise { - return this.currentRegion.resolver.resolveObjects([object], { onlyUnresolved: !forceResolve, skipInventory }); + return this.currentRegion.resolver.resolveObjects([object], options); } - async resolveObjects(objects: GameObject[], forceResolve = false, skipInventory = false, outputLog = false): Promise + async resolveObjects(objects: GameObject[], options: GetObjectsOptions): Promise { - return this.currentRegion.resolver.resolveObjects(objects, { onlyUnresolved: !forceResolve, skipInventory, outputLog }); + return this.currentRegion.resolver.resolveObjects(objects, options); + } + + public async fetchObjectInventory(object: GameObject): Promise + { + return this.currentRegion.resolver.getInventory(object); + } + + public async fetchObjectInventories(objects: GameObject[]): Promise + { + return this.currentRegion.resolver.getInventories(objects); } private waitForObjectByLocalID(localID: number, timeout: number): Promise @@ -1410,7 +1416,7 @@ export class RegionCommands extends CommandsBase if (!evt.object.resolvedAt) { // We need to get the full ObjectProperties so we can be sure this is or isn't a rez from inventory - await this.resolveObject(evt.object, false, true); + await this.resolveObject(evt.object, {}); } if (evt.createSelected && !evt.object.claimedForBuild) { @@ -1623,23 +1629,7 @@ export class RegionCommands extends CommandsBase const objs = await this.currentRegion.objects.getAllObjects(); if (options.resolve) { - const resolver = new ObjectResolver(this.currentRegion); - - const incl: GameObject[] = []; - for (const obj of objs) - { - if (!options.includeAvatars && obj.PCode === PCode.Avatar) - { - continue; - } - if (!options.includeTempObjects && (((obj.Flags ?? 0) & (PrimFlags.Temporary | PrimFlags.TemporaryOnRez)) !== 0)) - { - continue; - } - incl.push(obj); - } - - await resolver.resolveObjects(incl, options); + await this.currentRegion.resolver.resolveObjects(objs, options); } return objs; } diff --git a/lib/classes/public/Avatar.ts b/lib/classes/public/Avatar.ts index 6dd76a1..2962366 100644 --- a/lib/classes/public/Avatar.ts +++ b/lib/classes/public/Avatar.ts @@ -66,7 +66,7 @@ export class Avatar extends AvatarQueryResult const objs: GameObject[] = this._gameObject.region.objects.getObjectsByParent(this._gameObject.ID); for (const attachment of objs) { - this._gameObject.region.clientCommands.region.resolveObject(attachment, true, false).then(() => + this._gameObject.region.clientCommands.region.resolveObject(attachment, {}).then(() => { this.addAttachment(attachment); }).catch(() => @@ -238,8 +238,11 @@ export class Avatar extends AvatarQueryResult { if (obj.itemID !== undefined) { - this.attachments[obj.itemID.toString()] = obj; - this.onAttachmentAdded.next(obj); + if (this.attachments[obj.itemID.toString()] === undefined) + { + this.attachments[obj.itemID.toString()] = obj; + this.onAttachmentAdded.next(obj); + } } } diff --git a/lib/classes/public/GameObject.ts b/lib/classes/public/GameObject.ts index db9e978..11f4327 100644 --- a/lib/classes/public/GameObject.ts +++ b/lib/classes/public/GameObject.ts @@ -946,8 +946,13 @@ export class GameObject implements IGameObjectData throw new Error('Failed to add script to object'); } - updateInventory(): Promise + public async updateInventory(): Promise { + if (this.PCode === PCode.Avatar) + { + return; + } + const req = new RequestTaskInventoryMessage(); req.AgentData = { AgentID: this.region.agent.agentID, @@ -1501,10 +1506,14 @@ export class GameObject implements IGameObjectData private async getXML(xml: XMLNode, rootPrim: GameObject, linkNum: number, rootNode?: string): Promise { - if ((this.resolvedAt === undefined || this.resolvedAt === 0 || !this.resolvedInventory) && this.region?.resolver) + const resolver = this.region?.resolver; + if (resolver) { - await this.region.resolver.resolveObjects([this], { onlyUnresolved: false }); + await resolver.resolveObjects([this], { includeTempObjects: true }); + await resolver.getInventory(this); + await resolver.getCosts([this]); } + let root = xml; if (rootNode) { diff --git a/package-lock.json b/package-lock.json index f08264c..0b1a5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@caspertech/node-metaverse", - "version": "0.6.22", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@caspertech/node-metaverse", - "version": "0.6.22", + "version": "0.7.2", "license": "MIT", "dependencies": { "@caspertech/llsd": "^1.0.5", diff --git a/package.json b/package.json index f87c60d..4255623 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@caspertech/node-metaverse", - "version": "0.6.22", + "version": "0.7.2", "description": "A node.js interface for Second Life.", "main": "dist/lib/index.js", "types": "dist/lib/index.d.ts",