From a7e73f829183942d49d488173f59a469434253fc Mon Sep 17 00:00:00 2001 From: Lachlan Mason Date: Fri, 2 Jan 2026 17:00:28 +1100 Subject: [PATCH] Update TE2 destinations and exports --- lib/index.js | 4 + lib/parks/te2/te2.js | 193 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/lib/index.js b/lib/index.js index 85790388..89ffaf57 100644 --- a/lib/index.js +++ b/lib/index.js @@ -127,6 +127,8 @@ import { import { SeaWorldGoldCoast, WarnerBrosMovieWorld, + ParadiseCountry, + WetNWildGoldCoast, } from './parks/te2/te2.js'; export default { @@ -203,5 +205,7 @@ export default { Futuroscope, WarnerBrosMovieWorld, SeaWorldGoldCoast, + ParadiseCountry, + WetNWildGoldCoast, }, }; diff --git a/lib/parks/te2/te2.js b/lib/parks/te2/te2.js index 16db63f1..0d1c4e4d 100644 --- a/lib/parks/te2/te2.js +++ b/lib/parks/te2/te2.js @@ -28,6 +28,7 @@ export class TE2Destination extends Destination { * @param {string} [options.apidomain] TE2 API domain * @param {string} [options.apiuser] TE2 API username * @param {string} [options.apipass] TE2 API password + * @param {string} [options.rideStatusEndpoint] Endpoint for ride wait times and status * @param {Array} [options.rideTypes] Categories to classify as rides * @param {Array} [options.diningTypes] Categories to classify as dining * @param {Array} [options.showTypes] Categories to classify as shows @@ -41,6 +42,7 @@ export class TE2Destination extends Destination { options.apidomain = options.apidomain || 'te2.biz'; options.apiuser = options.apiuser || ''; options.apipass = options.apipass || ''; + options.rideStatusEndpoint = options.rideStatusEndpoint || null; options.rideTypes = options.rideTypes || ['Ride', 'Coasters', 'Family', 'ThrillRides', 'Kids', 'Rides & Attractions']; options.diningTypes = options.diningTypes || ['Snacks', 'wpDining', 'Meals', 'Dining']; options.showTypes = options.showTypes || ['Shows', 'Show', 'Entertainment', 'Live Entertainment', 'Presentation']; @@ -59,6 +61,9 @@ export class TE2Destination extends Destination { if (!this.config.apipass) throw new Error('Missing apipass'); this.config.apiBase = this.config.apiBase || `https://${this.config.subdomain}.${this.config.apidomain}`; + if (!this.config.rideStatusEndpoint) { + this.config.rideStatusEndpoint = `${this.config.apiBase}/rest/venue/${this.config.venueId}/poi/all/status`; + } const baseURLHostname = new URL(this.config.apiBase).hostname; @@ -72,7 +77,7 @@ export class TE2Destination extends Destination { 'Content-Type': 'application/json', }; - if (requestUrl.pathname.startsWith('/rest/')) { + if (requestUrl.pathname.startsWith('/rest/') || requestUrl.pathname.startsWith('/v2/')) { const credentials = Buffer.from(`${this.config.apiuser}:${this.config.apipass}`).toString('base64'); options.headers.Authorization = `Basic ${credentials}`; } @@ -85,8 +90,122 @@ export class TE2Destination extends Destination { */ async getPOIStatus() { '@cache|1'; - const resp = await this.http('GET', `${this.config.apiBase}/rest/venue/${this.config.venueId}/poi/all/status`); - return resp?.body ?? resp; + const resp = await this.http('GET', this.config.rideStatusEndpoint); + const rideData = resp?.body ?? resp; + if (!Array.isArray(rideData)) { + return rideData; + } + + const sampleEntry = rideData.find((entry) => !!entry); + if (sampleEntry?.status !== undefined && sampleEntry?.id !== undefined) { + return rideData; + } + + const statusEntries = []; + rideData.forEach((ride) => { + const te2RideId = this._extractTe2RideId(ride?.tags); + if (!te2RideId) return; + + const primaryQueue = this._getPrimaryQueue(ride); + const waitTimeMins = ride?.waitTimeMins ?? primaryQueue?.waitTimeMins; + const waitTimeValue = Number(waitTimeMins); + const isOpen = this._isRideOpen(ride, primaryQueue); + + const status = { + isOpen, + }; + + if (Number.isFinite(waitTimeValue)) { + status.waitTime = waitTimeValue; + } + + statusEntries.push({ + id: te2RideId, + status, + }); + }); + + return statusEntries; + } + + /** + * Collect TE2 ride IDs available from the ride status endpoint + * @return {Promise>} + * @private + */ + async _getRideStatusIds() { + const statusData = await this.getPOIStatus(); + const ids = new Set(); + if (Array.isArray(statusData)) { + statusData.forEach((entry) => { + if (entry?.id) { + ids.add(entry.id); + } + }); + } + return ids; + } + + /** + * Extract TE2 ride ID from tags returned by the wait time endpoint + * @param {Array} tags + * @return {string|null} + * @private + */ + _extractTe2RideId(tags) { + if (!Array.isArray(tags)) { + return null; + } + + for (const rawTag of tags) { + const tag = typeof rawTag === 'string' ? rawTag : rawTag?.label; + if (typeof tag !== 'string') continue; + + const match = tag.match(/^te2_rideid:(.+)$/i); + if (match && match[1]) { + return match[1]; + } + } + + return null; + } + + /** + * Select the primary queue entry for a ride + * @param {object} ride + * @return {object|null} + * @private + */ + _getPrimaryQueue(ride) { + const queues = Array.isArray(ride?.queues) ? ride.queues : []; + return queues.find((queue) => queue?.isPrimary) || queues.find((queue) => queue?.isDefault) || queues[0] || null; + } + + /** + * Determine if a ride is open based on queue or state data + * @param {object} ride + * @param {object|null} primaryQueue + * @return {boolean} + * @private + */ + _isRideOpen(ride, primaryQueue) { + if (typeof primaryQueue?.isOpen === 'boolean') { + return primaryQueue.isOpen; + } + + if (typeof ride?.isOpen === 'boolean') { + return ride.isOpen; + } + + if (typeof ride?.state === 'string') { + const normalized = ride.state.toLowerCase(); + if (normalized.includes('open')) return true; + if (normalized.includes('closed') || normalized.includes('down') || normalized.includes('maintenance')) { + return false; + } + } + + return false; } /** @@ -284,7 +403,7 @@ export class TE2Destination extends Destination { * @return {Promise>} Array of filtered entity objects * @private */ - async _getFilteredEntities({types, entities}, data, {includeFn} = {}) { + async _getFilteredEntities({types, entities}, data, {includeFn, excludeIds} = {}) { const poi = await this.getPOIData(); if (!Array.isArray(poi)) { return []; @@ -293,10 +412,17 @@ export class TE2Destination extends Destination { const typeSet = new Set(Array.isArray(types) ? types : []); const entitySet = new Set(Array.isArray(entities) ? entities : []); const seenIds = new Set(); + const excludeSet = excludeIds + ? new Set(Array.isArray(excludeIds) ? excludeIds : Array.from(excludeIds)) + : null; return poi.filter((entry) => { if (!entry?.id) return false; + if (excludeSet?.has(entry.id)) { + return false; + } + if (seenIds.has(entry.id)) { return false; } @@ -327,6 +453,9 @@ export class TE2Destination extends Destination { * @return {Promise>} Array of attraction entity objects */ async buildAttractionEntities() { + const diningTypes = await this.getDiningTypes(); + const excludeIds = new Set(Array.isArray(diningTypes?.entities) ? diningTypes.entities : []); + const rideStatusIds = await this._getRideStatusIds(); return await this._getFilteredEntities( await this.getAttractionTypes(), { @@ -334,7 +463,12 @@ export class TE2Destination extends Destination { attractionType: attractionType.ride, }, { + excludeIds, includeFn: (entry) => { + if (entry?.id && rideStatusIds.has(entry.id)) { + return true; + } + // Include entries with status data that have ride indicator tags const status = entry?.status; if (!status || (status.waitTime === undefined && status.operationalStatus === undefined)) { @@ -498,8 +632,8 @@ export class TE2Destination extends Destination { const poiMap = this._buildPOIMap(poiData); for (const event of events) { - if (!event?.id) return; - if (existingIds.has(event.id)) return; + if (!event?.id) continue; + if (existingIds.has(event.id)) continue; const entity = await this._buildShowEntityFromEvent(event, poiMap); showEntities.push(entity); @@ -520,7 +654,7 @@ export class TE2Destination extends Destination { async _fetchScheduleData({days = 120} = {}) { '@cache|1440'; const resp = await this.http('GET', `${this.config.apiBase}/v2/venues/${this.config.venueId}/venue-hours?days=${days}`); - return resp.body; + return resp?.body ?? resp; } /** @@ -573,10 +707,15 @@ export class TE2Destination extends Destination { if (!start) return; const startMs = Date.parse(start); - if (!Number.isFinite(startMs) || startMs < nowMs) return; + if (!Number.isFinite(startMs)) return; const end = slot?.end; const endMs = end ? Date.parse(end) : Number.NaN; + if (Number.isFinite(endMs)) { + if (endMs < nowMs) return; + } else if (startMs < nowMs) { + return; + } const showtime = { type: SCHEDULE_TYPE_PERFORMANCE, startTime: start, @@ -677,12 +816,15 @@ export class TE2Destination extends Destination { async buildEntityLiveData() { const liveDataMap = new Map(); + const attractionIds = new Set((await this.getAttractionEntities()).map((entry) => entry?._id).filter((id) => !!id)); + const restaurantIds = new Set((await this.getRestaurantEntities()).map((entry) => entry?._id).filter((id) => !!id)); const statusData = await this.getPOIStatus(); if (Array.isArray(statusData)) { statusData.forEach((entry) => { if (!entry?.status || !entry.id) return; if (entry.id.includes('_STANDING_OFFER_BEACON')) return; + if (!attractionIds.has(entry.id) && !restaurantIds.has(entry.id)) return; const liveData = liveDataMap.get(entry.id) || {_id: entry.id}; @@ -712,8 +854,19 @@ export class TE2Destination extends Destination { if (!slot?.startTime) return false; const startMoment = moment.tz(slot.startTime, this.config.timezone); if (!startMoment.isValid()) return false; - if (startMoment.isBefore(now)) return false; - return startMoment.format('YYYY-MM-DD') === todayKey; + + const endMoment = slot?.endTime ? moment.tz(slot.endTime, this.config.timezone) : null; + if (endMoment && !endMoment.isValid()) return false; + + const startKey = startMoment.format('YYYY-MM-DD'); + const endKey = endMoment ? endMoment.format('YYYY-MM-DD') : null; + if (startKey !== todayKey && endKey !== todayKey) return false; + + if (endMoment) { + return !endMoment.isBefore(now); + } + + return !startMoment.isBefore(now); }); }; @@ -738,6 +891,7 @@ export class SeaWorldGoldCoast extends TE2Destination { options.name = options.name || 'Sea World Gold Coast'; options.destinationId = options.destinationId || 'vrtp_sw_te2'; options.venueId = options.venueId || 'VRTP_SW'; + options.rideStatusEndpoint = options.rideStatusEndpoint || 'https://sw.vrtpfastpass.com.au/api/api/guest/rides'; super(options); } } @@ -747,6 +901,25 @@ export class WarnerBrosMovieWorld extends TE2Destination { options.name = options.name || 'Warner Bros. Movie World'; options.destinationId = options.destinationId || 'vrtp_mw_te2'; options.venueId = options.venueId || 'VRTP_MW'; + options.rideStatusEndpoint = options.rideStatusEndpoint || 'https://mw.vrtpfastpass.com.au/api/api/guest/rides'; + super(options); + } +} + +export class ParadiseCountry extends TE2Destination { + constructor(options = {}) { + options.name = options.name || 'Paradise Country'; + options.destinationId = options.destinationId || 'vrtp_pc_te2'; + options.venueId = options.venueId || 'VRTP_PC'; + super(options); + } +} + +export class WetNWildGoldCoast extends TE2Destination { + constructor(options = {}) { + options.name = options.name || 'Wet n Wild Gold Coast'; + options.destinationId = options.destinationId || 'vrtp_ww_te2'; + options.venueId = options.venueId || 'VRTP_WW'; super(options); } }