diff --git a/src/parks/enchantedparks/__tests__/enchantedparks.test.ts b/src/parks/enchantedparks/__tests__/enchantedparks.test.ts index 54ef257f..d5da339f 100644 --- a/src/parks/enchantedparks/__tests__/enchantedparks.test.ts +++ b/src/parks/enchantedparks/__tests__/enchantedparks.test.ts @@ -3,6 +3,13 @@ import {parseTribeEvents, type TribeEventsResponse, EnchantedParks} from '../enc import {parseICalFeed} from '../enchantedparks.js'; import {parseAttractionsPage} from '../enchantedparks.js'; import {parseShowsPage} from '../enchantedparks.js'; +import { + mapFeatureStatus, + normalizeRideName, + normalizeFeatureName, + matchFeaturesToLiveData, + type LiveFeature, +} from '../enchantedparks.js'; describe('parseTribeEvents', () => { const fixture: TribeEventsResponse = { @@ -413,3 +420,150 @@ describe('attractionLocations wiring on every EnchantedParks subclass', () => { } }); }); + +describe('mapFeatureStatus', () => { + // The operator's live feed uses free-text status strings with inconsistent + // casing (Open / OPEN, Temporarily Closed / TEMPORARILY_CLOSED). Map them to + // the framework's canonical statuses. + test('open variants → OPERATING', () => { + expect(mapFeatureStatus('Open')).toBe('OPERATING'); + expect(mapFeatureStatus('OPEN')).toBe('OPERATING'); + expect(mapFeatureStatus('opened')).toBe('OPERATING'); + }); + + test('temporary-closure variants → DOWN (the state the app hides)', () => { + expect(mapFeatureStatus('Temporarily Closed')).toBe('DOWN'); + expect(mapFeatureStatus('TEMPORARILY_CLOSED')).toBe('DOWN'); + expect(mapFeatureStatus('temp closed')).toBe('DOWN'); + }); + + test('all-day closure → CLOSED', () => { + expect(mapFeatureStatus('Closed')).toBe('CLOSED'); + expect(mapFeatureStatus('CLOSED')).toBe('CLOSED'); + }); + + test('unknown / empty → CLOSED (safe default)', () => { + expect(mapFeatureStatus('')).toBe('CLOSED'); + expect(mapFeatureStatus('something new')).toBe('CLOSED'); + }); +}); + +describe('feature-name normalization', () => { + // Feature names carry a park-code prefix ("WOF - ", "OOF - ") that the + // scraped ride names don't. Stripping it lets the two sources join by name. + test('strips the park-code prefix from feature names', () => { + expect(normalizeFeatureName('WOF - RipCord')).toBe(normalizeRideName('RipCord')); + expect(normalizeFeatureName('OOF - Typhoon')).toBe(normalizeRideName('Typhoon')); + expect(normalizeFeatureName('SSA - Oasis Bar')).toBe(normalizeRideName('Oasis Bar')); + }); + + test('does NOT strip a dash from an unprefixed scraped ride name', () => { + // A scraped name is passed through normalizeRideName, which must not eat + // leading words — only the uppercase-code prefix on the feature side goes. + expect(normalizeRideName('Timber Wolf')).toBe('timber wolf'); + expect(normalizeRideName('Wild Thing')).toBe('wild thing'); + }); + + test('folds curly apostrophes so both sources agree', () => { + expect(normalizeFeatureName('MA - Thunderhawk’s')).toBe(normalizeRideName("Thunderhawk's")); + }); +}); + +describe('matchFeaturesToLiveData', () => { + const rides = [ + {id: 'enchantedparks_attraction_WOF_ripcord', name: 'RipCord'}, + {id: 'enchantedparks_attraction_WOF_zambezi-zinger', name: 'Zambezi Zinger'}, + {id: 'enchantedparks_attraction_WOF_mamba', name: 'Mamba'}, + {id: 'enchantedparks_attraction_OOF_typhoon', name: 'Typhoon'}, + ]; + + const WOF = 'site-uuid-wof'; + const VF = 'site-uuid-vf'; + + const features: LiveFeature[] = [ + {name: 'WOF - RipCord', siteId: WOF, operationalStatus: 'Temporarily Closed'}, + {name: 'WOF - Zambezi Zinger', siteId: WOF, operationalStatus: 'Open'}, + {name: 'WOF - Mamba', siteId: WOF, operationalStatus: 'Closed'}, + {name: 'OOF - Typhoon', siteId: WOF, operationalStatus: 'OPEN'}, + // Non-ride POS feature — no matching ride entity, must be dropped. + {name: 'WOF - Ticket Sales', siteId: WOF, operationalStatus: 'Open'}, + // Feature from a different site — must be ignored even if name collides. + {name: 'VF - RipCord', siteId: VF, operationalStatus: 'Closed'}, + ]; + + test('maps each matched ride to its live status by id', () => { + const out = matchFeaturesToLiveData(features, [WOF], rides); + const byId = Object.fromEntries(out.map((l) => [l.id, l.status])); + expect(byId['enchantedparks_attraction_WOF_ripcord']).toBe('DOWN'); + expect(byId['enchantedparks_attraction_WOF_zambezi-zinger']).toBe('OPERATING'); + expect(byId['enchantedparks_attraction_WOF_mamba']).toBe('CLOSED'); + expect(byId['enchantedparks_attraction_OOF_typhoon']).toBe('OPERATING'); + }); + + test('drops features with no matching ride entity (POS, retail, gates)', () => { + const out = matchFeaturesToLiveData(features, [WOF], rides); + // 4 rides matched, Ticket Sales dropped. + expect(out).toHaveLength(4); + expect(out.every((l) => l.id.startsWith('enchantedparks_attraction_'))).toBe(true); + }); + + test('only uses features from the requested site id(s)', () => { + // The Valleyfair "VF - RipCord" (Closed) must not overwrite the Worlds of + // Fun RipCord (Temporarily Closed → DOWN). + const out = matchFeaturesToLiveData(features, [WOF], rides); + const ripcord = out.find((l) => l.id === 'enchantedparks_attraction_WOF_ripcord'); + expect(ripcord?.status).toBe('DOWN'); + }); + + test('returns empty when no site ids are supplied', () => { + expect(matchFeaturesToLiveData(features, [], rides)).toEqual([]); + }); +}); + +describe('buildLiveData wiring (stubbed network)', () => { + // Integration of the pieces without hitting the real feed: stub the two + // network-backed getters and assert buildLiveData joins them to the right + // entity ids and honours the empty-config guards. Full live integration is + // exercised via `npm run dev -- ` / `npm run health`. + async function makeWorldsOfFun(): Promise { + const mod = await import('../worldsoffun.js'); + const ParkClass = Object.values(mod)[0] as new () => EnchantedParks; + return new ParkClass(); + } + + test('joins feed features to scraped attractions by name', async () => { + const park = await makeWorldsOfFun(); + (park as any).liveStatusEndpoint = 'https://example.invalid/graphql'; + (park as any).liveStatusApiKey = 'test-key'; + (park as any).liveStatusSiteIds = ['test-site']; + (park as any).getFeatures = async (): Promise => [ + {name: 'WOF - Mamba', siteId: 'test-site', operationalStatus: 'Open'}, + {name: 'WOF - Prowler', siteId: 'test-site', operationalStatus: 'Temporarily Closed'}, + {name: 'OOF - Typhoon', siteId: 'test-site', operationalStatus: 'Closed'}, + {name: 'WOF - Ticket Sales', siteId: 'test-site', operationalStatus: 'Open'}, + ]; + (park as any).scrapeAttractions = async (path: string) => + path === 'oceans-of-fun' + ? [{slug: 'typhoon', name: 'Typhoon'}] + : [{slug: 'mamba', name: 'Mamba'}, {slug: 'prowler', name: 'Prowler'}]; + + const live = await (park as any).buildLiveData(); + const byId = Object.fromEntries(live.map((l: any) => [l.id, l.status])); + + expect(byId['enchantedparks_attraction_WOF_mamba']).toBe('OPERATING'); + expect(byId['enchantedparks_attraction_WOF_prowler']).toBe('DOWN'); + expect(byId['enchantedparks_attraction_OOF_typhoon']).toBe('CLOSED'); + // Ticket Sales has no scraped ride entity → dropped. + expect(live).toHaveLength(3); + }); + + test('returns no live data when the endpoint/key are unset', async () => { + const park = await makeWorldsOfFun(); + (park as any).liveStatusEndpoint = ''; + (park as any).liveStatusApiKey = ''; + (park as any).getFeatures = async () => [ + {name: 'WOF - Mamba', siteId: 'test-site', operationalStatus: 'Open'}, + ]; + expect(await (park as any).buildLiveData()).toEqual([]); + }); +}); diff --git a/src/parks/enchantedparks/enchantedparks.ts b/src/parks/enchantedparks/enchantedparks.ts index b045ed4d..4b733ab3 100644 --- a/src/parks/enchantedparks/enchantedparks.ts +++ b/src/parks/enchantedparks/enchantedparks.ts @@ -5,6 +5,7 @@ import config from '../../config.js'; import type {Entity, LiveData, EntitySchedule, ScheduleEntry} from '@themeparks/typelib'; import {constructDateTime, formatDate} from '../../datetime.js'; import {decodeHtmlEntities, stripHtmlTags} from '../../htmlUtils.js'; +import {createStatusMap} from '../../statusMap.js'; export type TribeEvent = { start_date: string; // "YYYY-MM-DD HH:MM:SS" @@ -208,6 +209,102 @@ export function parseShowsPage(html: string, categorySlug: string): AttractionSt return out; } +// ===== Live ride status (operator guest-experience API) ===== +// +// The operator runs a guest-experience platform whose live feed publishes a +// per-attraction `operationalStatus`. It's the only real-time source for these +// parks (there are no published wait times). The status distinguishes an +// all-day CLOSED ride from a temporarily-DOWN one — a distinction the operator +// app itself collapses. Endpoint + key are config (kept in `.env`), never +// hardcoded here. + +/** One attraction record from the operator's live feed. */ +export type LiveFeature = { + /** Display name, prefixed with a park code, e.g. "WOF - RipCord". */ + name: string; + /** + * Stable UUID of the site the feature belongs to (the feed's + * `parentAssignmentId`, which equals the site's `id`). Preferred over the + * display name for site selection — it survives renames. + */ + siteId: string; + /** Free-text operational status, e.g. "Open", "Temporarily Closed". */ + operationalStatus: string; +}; + +/** + * Map the feed's free-text status to a framework status. The feed uses + * inconsistent casing (Open/OPEN) and both spaced and underscored temporary- + * closure strings; `createStatusMap` lowercases before lookup so one entry per + * spelling covers every casing. Unknown/empty defaults to CLOSED and logs once. + */ +export const mapFeatureStatus = createStatusMap( + { + OPERATING: ['open', 'opened'], + DOWN: ['temporarily closed', 'temporarily_closed', 'temp closed', 'temp closed due weather'], + CLOSED: ['closed', 'not scheduled', ''], + }, + {parkName: 'EnchantedParks', defaultStatus: 'CLOSED'}, +); + +/** + * Normalize a scraped ride name for cross-source matching: fold curly + * apostrophes and trademark glyphs, lowercase, and collapse any run of + * non-alphanumerics to a single space. Does NOT strip leading words. + */ +export function normalizeRideName(name: string): string { + return name + .replace(/[‘’]/g, "'") + .replace(/[™®©]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +/** + * Normalize a live-feed feature name. Same as {@link normalizeRideName} but + * first strips the uppercase park-code prefix ("WOF - ", "OOF - ", "SSA - ") + * that the feed carries and the scraped ride names don't. The prefix strip is + * uppercase-only so it can't eat a leading word from a scraped name. + */ +export function normalizeFeatureName(name: string): string { + return normalizeRideName(name.replace(/^[A-Z]{2,5}\s*-\s*/, '')); +} + +/** + * Join live-feed features to scraped ride entities by normalized name and + * emit LiveData. Only features whose `siteId` is in `siteIds` are used, so a + * same-named ride at a sibling park can't bleed across. Features with no + * matching ride entity (POS registers, gates, retail carts, points offers) are + * dropped — the ride roster is the scraped entity list, not the feed. + */ +export function matchFeaturesToLiveData( + features: LiveFeature[], + siteIds: string[], + rides: Array<{id: string; name: string}>, +): LiveData[] { + if (!siteIds.length) return []; + const siteSet = new Set(siteIds); + const statusByName = new Map(); + for (const f of features) { + if (!siteSet.has(f.siteId)) continue; + const key = normalizeFeatureName(f.name); + if (!key) continue; + statusByName.set(key, mapFeatureStatus(f.operationalStatus)); + } + + const out: LiveData[] = []; + const emitted = new Set(); + for (const r of rides) { + if (emitted.has(r.id)) continue; + const status = statusByName.get(normalizeRideName(r.name)); + if (!status) continue; + emitted.add(r.id); + out.push({id: r.id, status} as LiveData); + } + return out; +} + export type ParkConfig = { /** Entity id, e.g. `enchantedparks_park_VF` */ id: string; @@ -227,6 +324,10 @@ export type ParkConfig = { location?: {latitude: number; longitude: number}; }; +/** Paginated list of live attraction statuses across every site. */ +const LIST_FEATURES_QUERY = + 'query($nextToken:String){ listFeatures(limit:300, nextToken:$nextToken){ nextToken items{ name parentAssignmentId operationalStatus } } }'; + class EnchantedParks extends Destination { /** Subdomain root, e.g. `https://valleyfair.enchantedparks.com` (no trailing slash) */ @config subdomain: string = ''; @@ -284,6 +385,21 @@ class EnchantedParks extends Destination { /** IANA timezone for the destination */ @config timezone: string = 'America/Chicago'; + /** + * Operator live guest-experience API — GraphQL endpoint and public key. + * Empty by default; real values come from `.env` (never hardcoded per the + * no-secrets rule). When either is empty, live data is skipped. + */ + @config liveStatusEndpoint: string = ''; + @config liveStatusApiKey: string = ''; + + /** + * Stable site UUID(s) in the operator's live feed that belong to this + * destination (matched against a feature's `parentAssignmentId`). Set per + * subclass. When unset, live data is skipped. + */ + liveStatusSiteIds?: string[]; + constructor(options?: DestinationConstructor) { super(options); this.addConfigPrefix('ENCHANTEDPARKS'); @@ -292,6 +408,7 @@ class EnchantedParks extends Destination { if (cfg.themePark) this.themePark = cfg.themePark; if (cfg.waterPark) this.waterPark = cfg.waterPark; if (cfg.destinationLocation) this.destinationLocation = cfg.destinationLocation; + if (cfg.liveStatusSiteIds) this.liveStatusSiteIds = cfg.liveStatusSiteIds; } /** Cache-key prefix so multiple Enchanted Parks don't collide on shared cache keys. */ @@ -575,9 +692,104 @@ class EnchantedParks extends Destination { return [...parks, ...attractions]; } + // ===== Live ride status ===== + + /** + * Fetch one page of the operator's live attraction feed. Cached 1min at the + * HTTP layer — every subclass POSTs the same query/body, so the shared HTTP + * cache serves all six parks from a single network round-trip per page. + * `retries: 0` — a transient miss just yields no live update this tick. + */ + @http({cacheSeconds: 120, retries: 0, healthCheckArgs: ['']}) + async fetchFeatures(nextToken: string | null): Promise { + return { + method: 'POST', + url: this.liveStatusEndpoint, + headers: { + 'x-api-key': this.liveStatusApiKey, + 'content-type': 'application/json', + }, + // Coerce an empty token (e.g. from the health check's args) to null so + // the feed returns the first page rather than erroring on an empty string. + body: {query: LIST_FEATURES_QUERY, variables: {nextToken: nextToken || null}}, + options: {json: true}, + } as any as HTTPObj; + } + + /** + * All live attraction records across every site, following pagination. + * Cached 2min so the feed is fetched once and reused by every park for the + * window. Returns [] on any failure so live-data build degrades to "no + * update" rather than throwing. + */ + @cache({ttlSeconds: 120}) + async getFeatures(): Promise { + if (!this.liveStatusEndpoint || !this.liveStatusApiKey) return []; + const out: LiveFeature[] = []; + let nextToken: string | null = null; + let guard = 0; + try { + do { + const resp = await this.fetchFeatures(nextToken); + const data = await resp.json(); + const box = data?.data?.listFeatures; + if (!box) break; + for (const it of box.items ?? []) { + if (!it?.name) continue; + out.push({ + name: it.name, + siteId: it.parentAssignmentId ?? '', + operationalStatus: it.operationalStatus ?? '', + }); + } + nextToken = box.nextToken ?? null; + } while (nextToken && ++guard < 20); + } catch { + return []; + } + return out; + } + + /** + * Rebuild the (entity id, name) pairs for this destination's attractions, + * using the same id formula as {@link buildEntityList} so live data attaches + * to the entities already emitted. Restaurants and shows are excluded — the + * live feed only carries attractions. + */ + private async getAttractionStubs(): Promise> { + const stubs: Array<{id: string; name: string}> = []; + + let waterParkSlugs = new Set(); + if (this.waterPark) { + const wpRides = await this.scrapeAttractions(this.waterPark.ridesPath); + waterParkSlugs = new Set(wpRides.map(r => r.slug)); + for (const r of wpRides) { + stubs.push({id: `enchantedparks_attraction_${this.waterPark.code}_${r.slug}`, name: r.name}); + } + } + + if (this.themePark) { + const tpRides = await this.scrapeAttractions(this.themePark.ridesPath); + for (const r of tpRides) { + if (waterParkSlugs.has(r.slug)) continue; + stubs.push({id: `enchantedparks_attraction_${this.themePark.code}_${r.slug}`, name: r.name}); + } + } + + return stubs; + } + protected async buildLiveData(): Promise { - // No live wait-times source for Enchanted Parks until their app launches. - return []; + // Live per-attraction status from the operator's guest-experience feed. + // Requires the endpoint/key (from .env) and the site-name mapping. + if (!this.liveStatusEndpoint || !this.liveStatusApiKey) return []; + if (!this.liveStatusSiteIds?.length) return []; + + const features = await this.getFeatures(); + if (!features.length) return []; + + const rides = await this.getAttractionStubs(); + return matchFeaturesToLiveData(features, this.liveStatusSiteIds, rides); } protected async buildSchedules(): Promise { diff --git a/src/parks/enchantedparks/galvestonislandwaterpark.ts b/src/parks/enchantedparks/galvestonislandwaterpark.ts index b16d27a9..b698438e 100644 --- a/src/parks/enchantedparks/galvestonislandwaterpark.ts +++ b/src/parks/enchantedparks/galvestonislandwaterpark.ts @@ -16,6 +16,7 @@ export class GalvestonIslandWaterpark extends EnchantedParks { }, }); this.attractionLocations = locations; + this.liveStatusSiteIds ??= ['33fd9067-be6d-4cdd-aa28-ae098d460147']; // Galveston this.destinationLocation ??= {latitude: 29.2767, longitude: -94.8231}; // Water-park-only destination: the upstream site exposes a single // `/rides-and-experiences/attractions/` list under the "Waterpark Hours" diff --git a/src/parks/enchantedparks/greatescapeparks.ts b/src/parks/enchantedparks/greatescapeparks.ts index 5871fe6b..a9a206a8 100644 --- a/src/parks/enchantedparks/greatescapeparks.ts +++ b/src/parks/enchantedparks/greatescapeparks.ts @@ -16,6 +16,12 @@ export class GreatEscapeParks extends EnchantedParks { }, }); this.attractionLocations = locations; + // The Great Escape's water park is a separate site ("Whitewater Bay") in + // the live feed; include both so its rides get status too. + this.liveStatusSiteIds ??= [ + 'd0437b8a-ca50-4fed-9f8e-fcd50b2af04b', // Great Escape + 'ca6dc180-e3ef-4151-9c12-6cea9c43204d', // Whitewater Bay + ]; this.destinationLocation ??= {latitude: 43.3506, longitude: -73.6889}; this.themePark ??= { id: 'enchantedparks_park_GE', diff --git a/src/parks/enchantedparks/michigansadventure.ts b/src/parks/enchantedparks/michigansadventure.ts index 755b3288..6a408b83 100644 --- a/src/parks/enchantedparks/michigansadventure.ts +++ b/src/parks/enchantedparks/michigansadventure.ts @@ -16,6 +16,7 @@ export class MichigansAdventure extends EnchantedParks { }, }); this.attractionLocations = locations; + this.liveStatusSiteIds ??= ['09c32d20-e8e4-482c-a200-d098d4007672']; // Michigan's Adventure this.destinationLocation ??= {latitude: 43.3411, longitude: -86.2625}; this.themePark ??= { id: 'enchantedparks_park_MA', diff --git a/src/parks/enchantedparks/midamericaparks.ts b/src/parks/enchantedparks/midamericaparks.ts index 53844867..558095fd 100644 --- a/src/parks/enchantedparks/midamericaparks.ts +++ b/src/parks/enchantedparks/midamericaparks.ts @@ -16,6 +16,8 @@ export class MidAmericaParks extends EnchantedParks { }, }); this.attractionLocations = locations; + // Six Flags St. Louis is the "St Louis" site in the operator's live feed. + this.liveStatusSiteIds ??= ['535e5890-11cb-4c95-ac07-a927c6af5398']; // St Louis this.destinationLocation ??= {latitude: 38.5128, longitude: -90.6724}; this.themePark ??= { id: 'enchantedparks_park_MAP', diff --git a/src/parks/enchantedparks/valleyfair.ts b/src/parks/enchantedparks/valleyfair.ts index fba1e4f4..d8f76a5b 100644 --- a/src/parks/enchantedparks/valleyfair.ts +++ b/src/parks/enchantedparks/valleyfair.ts @@ -20,6 +20,7 @@ export class Valleyfair extends EnchantedParks { // these if a caller wants to supply different ones — only set defaults // when the caller hasn't. this.attractionLocations = locations; + this.liveStatusSiteIds ??= ['146a4170-9ef0-4fbd-936f-1d1544c8ce88']; // Valleyfair this.destinationLocation ??= {latitude: 44.7977, longitude: -93.4399}; this.themePark ??= { id: 'enchantedparks_park_VF', diff --git a/src/parks/enchantedparks/worldsoffun.ts b/src/parks/enchantedparks/worldsoffun.ts index b184d7e8..656e905e 100644 --- a/src/parks/enchantedparks/worldsoffun.ts +++ b/src/parks/enchantedparks/worldsoffun.ts @@ -16,6 +16,9 @@ export class WorldsOfFun extends EnchantedParks { }, }); this.attractionLocations = locations; + // Oceans of Fun rides appear under the "Worlds of Fun" site in the live + // feed (prefixed "OOF -"); ride-name matching folds both into this one site. + this.liveStatusSiteIds ??= ['86792ef7-20f2-417d-8efd-778d3c70ce0e']; // Worlds of Fun this.destinationLocation ??= {latitude: 39.1746, longitude: -94.4886}; this.themePark ??= { id: 'enchantedparks_park_WOF',