From 34141ad9a464589c2b2926369c1b8f84098f3b31 Mon Sep 17 00:00:00 2001 From: Lachlan Mason Date: Wed, 8 Apr 2026 20:54:01 +1000 Subject: [PATCH 1/6] feat(oceanpark): add Ocean Park Hong Kong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth token (optoken) injected via @inject; device UUID persisted 90 days via @cache; dynamic TTL from API tokenExpire field - Coordinate affine transform: fetches reference_points.json from map subdomain and projects pixel positions to lat/lng for all entities - Entities: rides, transport, shows, dining — with height restriction tags, wet rides, pregnant warning, and FastPass (paidReturnTime) - Live data: wait times + today's operating hours (rides/transport), showtimes from activityList (shows) - Schedules: 30-day park operating hours, parking, Summit zone entries Co-Authored-By: Claude Sonnet 4.6 --- README.md | 723 ++++++++++++++++++++++--------- TODO.MD | 3 +- src/parks/oceanpark/oceanpark.ts | 661 ++++++++++++++++++++++++++++ 3 files changed, 1189 insertions(+), 198 deletions(-) create mode 100644 src/parks/oceanpark/oceanpark.ts diff --git a/README.md b/README.md index 532cf4541..6d000fd3c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ -# ThemeParks.wiki Park Data Backend +# ThemeParks.wiki Parks API -An open-source TypeScript library for fetching real-time theme park data — wait times, schedules, and entity metadata — from 75+ destinations worldwide. - -This library powers the free API at [ThemeParks.wiki](https://themeparks.wiki). - -**License:** MIT +[![Unit Test](https://github.com/ThemeParks/parksapi/actions/workflows/unit_test.js.yml/badge.svg)](https://github.com/ThemeParks/parksapi/actions/workflows/unit_test.js.yml) [![pages-build-deployment](https://github.com/ThemeParks/parksapi/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/ThemeParks/parksapi/actions/workflows/pages/pages-build-deployment) ## Sponsored By @@ -13,214 +9,547 @@ This library powers the free API at [ThemeParks.wiki](https://themeparks.wiki). TouringPlans.com - TouringPlans.com + + TouringPlans.com +
- Queue Times + QueueTimes - Queue Times + + Queue Times +
-
- - Pocket'Park - - - Pocket'Park - -
- -## Quick Start - -**Requirements:** Node.js 24+, npm 11+ - -```bash -git clone https://github.com/ThemeParks/parksapi.git -cd parksapi -npm install -touch .env # Add your API credentials -npm run dev # Test all parks -``` - -Most parks require API credentials not provided in this repo — you must source these yourself. - -## Usage - -```typescript -import {getDestinationById} from '@themeparks/parksapi'; - -const dest = await getDestinationById('universalorlando'); -const park = new dest.DestinationClass(); - -const entities = await park.getEntities(); // Rides, shows, restaurants -const liveData = await park.getLiveData(); // Wait times, statuses -const schedules = await park.getSchedules(); // Operating hours -``` - -## Client Libraries +## ThemeParks.wiki API + +[API Documentation](https://themeparks.github.io/parksapi/) + +This is a backend module to fetch and query live data for themeparks. This source code powers the free-to-use API at [ThemeParks.wiki](https://themeparks.wiki) + +To fetch data from the API, you should look at the client libraries: +* https://github.com/ThemeParks/ThemeParks_JavaScript +* https://github.com/ThemeParks/ThemeParks_Python + +Fetching and parsing logic is provided in this source code. Most parks require some form of credentials, which are not supplied in this repo. You will need to source these yourself. + +General support is available for the ThemeParks.wiki API, and not this source code (except for Sponsors with support benefits). + +`test.js` contains a basic set of sanity checks and output validation, which also shows how a destination object can be accessed. + +Each destination generates *entities*. Entities can be of various types: + +* Destinations + * i.e, Resorts + * These are not called resorts to avoid confusion for parks that are not part of resorts + * eg. Walt Disney World Resort +* Parks + * All parks must be within a Destination entity + * eg. Magic Kingdom +* Attraction + * A ride / transport / etc. + * Attraction entities must be within a Destination entity (usually also within a park, but not always) + * eg. Pirates of the Carribean +* Resturant + * A dining location within a destination + * eg. Casey's Corner +* Show + * A show / parade entity with scheduled show times + * eg. Main Street Electrical Parade + +*Entity Types Being Finalised - Destinations, Parks, and Attractions however, are well supported and unlikely to change* + +## Adding Destinations + +Best current documentation for this is to check out `scripts/templateDestination.js` and look at the existing supported destinations. + +Setup `index.js` with your new class, and edit `test.js` to test your destination to check everything is setup correctly. + +## Destinations + + +* WaltDisneyWorldResort +* DisneylandResort +* DisneylandParis +* TokyoDisneyResort +* HongKongDisneyland +* ShanghaiDisneylandResort +* UniversalStudios +* UniversalOrlando +* EuropaPark +* Efteling +* Phantasialand +* SeaworldOrlando +* SeaworldSanAntonio +* SeaworldSanDiego +* BuschGardensTampa +* BuschGardensWilliamsburg +* AltonTowers +* ThorpePark +* ChessingtonWorldOfAdventures +* LegolandWindsor +* LegolandOrlando +* LegolandCalifornia +* LegolandBillund +* LegolandDeutschland +* Gardaland +* PortAventuraWorld +* ParcAsterix +* Toverland +* Dollywood +* SilverDollarCity +* Plopsaland +* HolidayPark +* Bellewaerde +* WalibiHolland +* WalibiBelgium +* HeidePark +* Liseberg +* CedarPoint +* KnottsBerryFarm +* CaliforniasGreatAmerica +* CanadasWonderland +* Carowinds +* KingsIsland +* DorneyPark +* KingsDominion +* MichigansAdventure +* ValleyFair +* WorldsOfFun +* Hersheypark +* SixFlags +* HansaPark +* Knoebels +* OceanParkHongKong + -To fetch data from the ThemeParks.wiki API (rather than running this library directly): +## Configuration -- [JavaScript Client](https://github.com/ThemeParks/ThemeParks_JavaScript) -- [Python Client](https://github.com/ThemeParks/ThemeParks_Python) +Destination objects can be configured by either passing an object of variables into the constructor, or using environment variables (the preferred way). -## Commands +To use environment variables, create a file called `.env` in the working directory of your project. -```bash -npm run build # Compile TypeScript -npm run dev # Test all parks -npm run dev -- # Test specific park (e.g. universalorlando) -npm run dev -- --list # List all available park IDs -npm test # Run unit tests -npm run test:coverage # Coverage report -npm run health # Health check all endpoints -``` +Environment variables are structured as: +* Class name (upper case) +* Underscore _ +* Variable name (upper case) -## Supported Destinations - -75 destinations across Disney, Universal, Cedar Fair, Six Flags, Merlin, and many more. - -Run `npm run dev -- --list` for the full list with IDs and categories, or see below: - -
-All destinations - -| Destination | ID | -|---|---| -| Alton Towers | `altontowers` | -| Bellewaerde | `bellewaerde` | -| Bobbejaanland | `bobbejaanland` | -| Busch Gardens Tampa | `buschgardenstampa` | -| Busch Gardens Williamsburg | `buschgardenswilliamsburg` | -| California's Great America | `californiasgreatamerica` | -| Canada's Wonderland | `canadaswonderland` | -| Carowinds | `carowinds` | -| Cedar Point | `cedarpoint` | -| Chessington World of Adventures | `chessingtonworldofadventures` | -| Chimelong | `chimelong` | -| Disneyland Paris | `disneylandparis` | -| Djurs Sommerland | `djurssommerland` | -| Dollywood | `dollywood` | -| Dorney Park | `dorneypark` | -| Efteling | `efteling` | -| Europa-Park | `europapark` | -| Everland | `everland` | -| Futuroscope | `futuroscope` | -| Gardaland | `gardaland` | -| Hansa-Park | `hansapark` | -| Heide Park | `heidepark` | -| Hersheypark | `hersheypark` | -| Kennywood | `kennywood` | -| Kings Dominion | `kingsdominion` | -| Kings Island | `kingsisland` | -| Knoebels | `knoebels` | -| Knott's Berry Farm | `knottsberryfarm` | -| Legoland Billund | `legolandbillund` | -| Legoland California | `legolandcalifornia` | -| Legoland Deutschland | `legolanddeutschland` | -| Legoland Japan | `legolandjapan` | -| Legoland Korea | `legolandkorea` | -| Legoland New York | `legolandnewyork` | -| Legoland Orlando | `legolandorlando` | -| Legoland Windsor | `legolandwindsor` | -| Liseberg | `liseberg` | -| Lotte World | `lotteworld` | -| Michigan's Adventure | `michigansadventure` | -| Mirabilandia | `mirabilandia` | -| Movie Park Germany | `movieparkgermany` | -| Parc Asterix | `parcasterix` | -| Paradise Country | `paradisecountry` | -| Parque de Atracciones Madrid | `parquedeatraccionesmadrid` | -| Parque Warner Madrid | `parquewarnermadrid` | -| Paultons Park | `paultonspark` | -| Peppa Pig Theme Park Florida | `peppapigthemeparkflorida` | -| Phantasialand | `phantasialand` | -| Plopsaland | `plopsaland` | -| Plopsaland Deutschland | `plopsalanddeutschland` | -| PortAventura World | `portaventuraworld` | -| Sea World Gold Coast | `seaworldgoldcoast` | -| SeaWorld Orlando | `seaworldorlando` | -| SeaWorld San Antonio | `seaworldsanantonio` | -| SeaWorld San Diego | `seaworldsandiego` | -| Shanghai Disneyland Resort | `shanghaidisneylandresort` | -| Silver Dollar City | `silverdollarcity` | -| Six Flags | `sixflags` | -| Six Flags Qiddiya City | `sixflagsqiddiyacity` | -| Thorpe Park | `thorpepark` | -| Tokyo Disney Resort | `tokyodisneyresort` | -| Toverland | `toverland` | -| Universal Orlando | `universalorlando` | -| Universal Singapore | `universalsingapore` | -| Universal Studios | `universalstudios` | -| Universal Studios Beijing | `universalstudiosbeijing` | -| Universal Studios Japan | `universalstudiosjapan` | -| Valleyfair | `valleyfair` | -| Walibi Belgium | `walibibelgium` | -| Walibi Holland | `walibiholland` | -| Walibi Rhone-Alpes | `walibirhonealpes` | -| Warner Bros. Movie World | `warnerbrosmovieworld` | -| Wet'n'Wild Gold Coast | `wetnwildgoldcoast` | -| Worlds of Fun | `worldsoffun` | - -
- -## Entity Types - -Each destination produces **entities** of the following types: - -- **Destination** — A resort or group of parks (e.g., Walt Disney World Resort) -- **Park** — A theme park within a destination (e.g., Magic Kingdom) -- **Attraction** — A ride, transport, or similar experience (e.g., Pirates of the Caribbean) -- **Show** — A performance or parade with scheduled show times -- **Restaurant** — A dining location +For example, the class `WaltDisneyWorldResort` has a `resortId` configuration variable which can be loaded through the environment variable `WALTDISNEYWORLDRESORT_RESORTID` -## Configuration +Some classes expose "parent scopes" to allow configuring environment variables once for a range of destinations. For example, Attractions.io's base URL can be configured once through `ATTRACTIONSIO_BASEURL` rather than configuring it identically for every destination. -Environment variables follow the pattern `{CLASSNAME}_{PROPERTY}`: +Environment Variables (auto-generated): + ``` -UNIVERSALORLANDO_APIKEY=your-key-here -EFTELING_APPVERSION=5.0.0 +WALTDISNEYWORLDRESORT_RESORTID +WALTDISNEYWORLDRESORT_RESORTSHORTCODE +WALTDISNEYWORLDRESORT_PARKIDS +WALTDISNEYWORLDRESORT_VIRTUALQUEUEURL +WALTDISNEYWORLDRESORT_GENIEDATA +WALTDISNEYWORLDRESORT_SLUG +DISNEYLANDRESORT_RESORTID +DISNEYLANDRESORT_RESORTSHORTCODE +DISNEYLANDRESORT_PARKIDS +DISNEYLANDRESORT_VIRTUALQUEUEURL +DISNEYLANDRESORT_GENIEDATA +DISNEYLANDRESORT_SLUG +DISNEYLANDPARIS_APIKEY +DISNEYLANDPARIS_APIBASE +DISNEYLANDPARIS_APIBASEWAITTIMES +DISNEYLANDPARIS_LANGUAGE +DISNEYLANDPARIS_STANDBYAPIBASE +DISNEYLANDPARIS_STANDBYAPIKEY +DISNEYLANDPARIS_STANDBYAUTHURL +DISNEYLANDPARIS_STANDBYAPIREFRESHTOKEN +DISNEYLANDPARIS_PREMIERACCESSAPIKEY +DISNEYLANDPARIS_PREMIERACCESSURL +DISNEYLANDPARIS_USERAGENT +TOKYODISNEYRESORT_APIKEY +TOKYODISNEYRESORT_APIAUTH +TOKYODISNEYRESORT_APIOS +TOKYODISNEYRESORT_APIBASE +TOKYODISNEYRESORT_APIVERSION +TOKYODISNEYRESORT_PARKIDS +TOKYODISNEYRESORT_FALLBACKDEVICEID +HONGKONGDISNEYLAND_RESORTID +HONGKONGDISNEYLAND_DESTINATIONID +HONGKONGDISNEYLAND_RESORTSHORTCODE +HONGKONGDISNEYLAND_CULTUREFILTER +HONGKONGDISNEYLAND_PARKIDS +HONGKONGDISNEYLAND_SLUG +HONGKONGDISNEYLAND_VIRTUALQUEUEURL +HONGKONGDISNEYLAND_GENIEDATA +SHANGHAIDISNEYLANDRESORT_APIBASE +SHANGHAIDISNEYLANDRESORT_APIAUTH +SHANGHAIDISNEYLANDRESORT_PARKIDS +UNIVERSALSTUDIOS_CITY +UNIVERSALSTUDIOS_RESORTSLUG +UNIVERSALSTUDIOS_RESORTKEY +UNIVERSALSTUDIOS_SECRETKEY +UNIVERSALSTUDIOS_APPKEY +UNIVERSALSTUDIOS_VQUEUEURL +UNIVERSALSTUDIOS_BASEURL +UNIVERSALSTUDIOS_ASSETSBASE +UNIVERSALORLANDO_CITY +UNIVERSALORLANDO_RESORTSLUG +UNIVERSALORLANDO_RESORTKEY +UNIVERSALORLANDO_SECRETKEY +UNIVERSALORLANDO_APPKEY +UNIVERSALORLANDO_VQUEUEURL +UNIVERSALORLANDO_BASEURL +UNIVERSALORLANDO_ASSETSBASE +EUROPAPARK_PARKS +EFTELING_APIKEY +EFTELING_APIVERSION +EFTELING_APPVERSION +EFTELING_SEARCHURL +EFTELING_WAITTIMESURL +PHANTASIALAND_APIBASE +SEAWORLDORLANDO_RESORTIDS +SEAWORLDORLANDO_RESORTID +SEAWORLDORLANDO_RESORTSLUG +SEAWORLDORLANDO_APPID +SEAWORLDORLANDO_APPVERSION +SEAWORLDORLANDO_BASEURL +SEAWORLDSANANTONIO_RESORTIDS +SEAWORLDSANANTONIO_RESORTID +SEAWORLDSANANTONIO_RESORTSLUG +SEAWORLDSANANTONIO_APPID +SEAWORLDSANANTONIO_APPVERSION +SEAWORLDSANANTONIO_BASEURL +SEAWORLDSANDIEGO_RESORTIDS +SEAWORLDSANDIEGO_RESORTID +SEAWORLDSANDIEGO_RESORTSLUG +SEAWORLDSANDIEGO_APPID +SEAWORLDSANDIEGO_APPVERSION +SEAWORLDSANDIEGO_BASEURL +BUSCHGARDENSTAMPA_RESORTIDS +BUSCHGARDENSTAMPA_RESORTID +BUSCHGARDENSTAMPA_RESORTSLUG +BUSCHGARDENSTAMPA_APPID +BUSCHGARDENSTAMPA_APPVERSION +BUSCHGARDENSTAMPA_BASEURL +BUSCHGARDENSWILLIAMSBURG_RESORTIDS +BUSCHGARDENSWILLIAMSBURG_RESORTID +BUSCHGARDENSWILLIAMSBURG_RESORTSLUG +BUSCHGARDENSWILLIAMSBURG_APPID +BUSCHGARDENSWILLIAMSBURG_APPVERSION +BUSCHGARDENSWILLIAMSBURG_BASEURL +ALTONTOWERS_DESTINATIONID +ALTONTOWERS_PARKID +ALTONTOWERS_INITIALDATAVERSION +ALTONTOWERS_APPBUILD +ALTONTOWERS_APPVERSION +ALTONTOWERS_BASEURL +ALTONTOWERS_DEVICEIDENTIFIER +ALTONTOWERS_APIKEY +ALTONTOWERS_CALENDARURL +THORPEPARK_DESTINATIONID +THORPEPARK_PARKID +THORPEPARK_INITIALDATAVERSION +THORPEPARK_APPBUILD +THORPEPARK_APPVERSION +THORPEPARK_BASEURL +THORPEPARK_DEVICEIDENTIFIER +THORPEPARK_APIKEY +THORPEPARK_CALENDARURL +CHESSINGTONWORLDOFADVENTURES_DESTINATIONID +CHESSINGTONWORLDOFADVENTURES_PARKID +CHESSINGTONWORLDOFADVENTURES_INITIALDATAVERSION +CHESSINGTONWORLDOFADVENTURES_APPBUILD +CHESSINGTONWORLDOFADVENTURES_APPVERSION +CHESSINGTONWORLDOFADVENTURES_BASEURL +CHESSINGTONWORLDOFADVENTURES_DEVICEIDENTIFIER +CHESSINGTONWORLDOFADVENTURES_APIKEY +CHESSINGTONWORLDOFADVENTURES_CALENDARURL +LEGOLANDWINDSOR_DESTINATIONID +LEGOLANDWINDSOR_PARKID +LEGOLANDWINDSOR_INITIALDATAVERSION +LEGOLANDWINDSOR_APPBUILD +LEGOLANDWINDSOR_APPVERSION +LEGOLANDWINDSOR_BASEURL +LEGOLANDWINDSOR_DEVICEIDENTIFIER +LEGOLANDWINDSOR_APIKEY +LEGOLANDWINDSOR_CALENDARURL +LEGOLANDORLANDO_DESTINATIONID +LEGOLANDORLANDO_PARKID +LEGOLANDORLANDO_INITIALDATAVERSION +LEGOLANDORLANDO_APPBUILD +LEGOLANDORLANDO_APPVERSION +LEGOLANDORLANDO_BASEURL +LEGOLANDORLANDO_DEVICEIDENTIFIER +LEGOLANDORLANDO_APIKEY +LEGOLANDORLANDO_CALENDARURL +LEGOLANDCALIFORNIA_DESTINATIONID +LEGOLANDCALIFORNIA_PARKID +LEGOLANDCALIFORNIA_INITIALDATAVERSION +LEGOLANDCALIFORNIA_APPBUILD +LEGOLANDCALIFORNIA_APPVERSION +LEGOLANDCALIFORNIA_BASEURL +LEGOLANDCALIFORNIA_DEVICEIDENTIFIER +LEGOLANDCALIFORNIA_APIKEY +LEGOLANDCALIFORNIA_CALENDARURL +LEGOLANDBILLUND_DESTINATIONID +LEGOLANDBILLUND_PARKID +LEGOLANDBILLUND_INITIALDATAVERSION +LEGOLANDBILLUND_APPBUILD +LEGOLANDBILLUND_APPVERSION +LEGOLANDBILLUND_BASEURL +LEGOLANDBILLUND_DEVICEIDENTIFIER +LEGOLANDBILLUND_APIKEY +LEGOLANDBILLUND_CALENDARURL +LEGOLANDDEUTSCHLAND_DESTINATIONID +LEGOLANDDEUTSCHLAND_PARKID +LEGOLANDDEUTSCHLAND_INITIALDATAVERSION +LEGOLANDDEUTSCHLAND_APPBUILD +LEGOLANDDEUTSCHLAND_APPVERSION +LEGOLANDDEUTSCHLAND_BASEURL +LEGOLANDDEUTSCHLAND_DEVICEIDENTIFIER +LEGOLANDDEUTSCHLAND_APIKEY +LEGOLANDDEUTSCHLAND_CALENDARURL +GARDALAND_DESTINATIONID +GARDALAND_PARKID +GARDALAND_INITIALDATAVERSION +GARDALAND_APPBUILD +GARDALAND_APPVERSION +GARDALAND_BASEURL +GARDALAND_DEVICEIDENTIFIER +GARDALAND_APIKEY +GARDALAND_CALENDARURL +PORTAVENTURAWORLD_APIBASE +PORTAVENTURAWORLD_GUESTUSERNAME +PORTAVENTURAWORLD_GUESTPASSWORD +PORTAVENTURAWORLD_WAITTIMEURL +PARCASTERIX_APIBASE +PARCASTERIX_LANGUAGE +TOVERLAND_APIBASE +TOVERLAND_CALENDARURL +TOVERLAND_AUTHTOKEN +TOVERLAND_LANGUAGES +DOLLYWOOD_DESTINATIONID +DOLLYWOOD_DESTINATIONSLUG +DOLLYWOOD_CRMBASEURL +DOLLYWOOD_CRMGUID +DOLLYWOOD_CRMATTRACTIONSID +DOLLYWOOD_CRMDININGID +DOLLYWOOD_CRMSHOWID +DOLLYWOOD_APIBASE +DOLLYWOOD_CRMAUTH +SILVERDOLLARCITY_DESTINATIONID +SILVERDOLLARCITY_DESTINATIONSLUG +SILVERDOLLARCITY_CRMBASEURL +SILVERDOLLARCITY_CRMGUID +SILVERDOLLARCITY_CRMATTRACTIONSID +SILVERDOLLARCITY_CRMDININGID +SILVERDOLLARCITY_CRMSHOWID +SILVERDOLLARCITY_APIBASE +SILVERDOLLARCITY_CRMAUTH +PLOPSALAND_DESTINATIONSLUG +PLOPSALAND_PARKSLUG +PLOPSALAND_BASEURL +PLOPSALAND_BASELANG +PLOPSALAND_CLIENTID +PLOPSALAND_CLIENTSECRET +HOLIDAYPARK_DESTINATIONSLUG +HOLIDAYPARK_PARKSLUG +HOLIDAYPARK_BASEURL +HOLIDAYPARK_BASELANG +HOLIDAYPARK_CLIENTID +HOLIDAYPARK_CLIENTSECRET +BELLEWAERDE_BASEURL +BELLEWAERDE_DESTINATIONSLUG +BELLEWAERDE_PARKSLUG +BELLEWAERDE_APISHORTCODE +BELLEWAERDE_CULTURE +BELLEWAERDE_APIKEY +WALIBIHOLLAND_APIKEY +WALIBIHOLLAND_BASEURL +WALIBIHOLLAND_DESTINATIONSLUG +WALIBIHOLLAND_PARKSLUG +WALIBIHOLLAND_APISHORTCODE +WALIBIHOLLAND_CULTURE +HEIDEPARK_DESTINATIONID +HEIDEPARK_PARKID +HEIDEPARK_INITIALDATAVERSION +HEIDEPARK_APPBUILD +HEIDEPARK_APPVERSION +HEIDEPARK_BASEURL +HEIDEPARK_DEVICEIDENTIFIER +HEIDEPARK_APIKEY +HEIDEPARK_CALENDARURL +LISEBERG_RESORTID +LISEBERG_BASEURL +CEDARPOINT_PARKID +CEDARPOINT_DESTINATIONID +CEDARPOINT_BASEURL +CEDARPOINT_REALTIMEBASEURL +CEDARPOINT_CONFIGPATH +CEDARPOINT_LONGITUDE +CEDARPOINT_LATITUDE +CEDARPOINT_EXTRAATTRACTIONCATEGORYTYPES +CEDARPOINT_EXTRASHOWCATEGORYTYPES +CEDARPOINT_EXTRARESTAURANTCATEGORYTYPES +CEDARPOINT_ATTRACTIONCATEGORIES +CEDARPOINT_SHOWCATEGORIES +CEDARPOINT_DININGCATEGORIES +KNOTTSBERRYFARM_PARKID +KNOTTSBERRYFARM_DESTINATIONID +KNOTTSBERRYFARM_EXTRAATTRACTIONCATEGORYTYPES +KNOTTSBERRYFARM_BASEURL +KNOTTSBERRYFARM_REALTIMEBASEURL +KNOTTSBERRYFARM_CONFIGPATH +KNOTTSBERRYFARM_LONGITUDE +KNOTTSBERRYFARM_LATITUDE +KNOTTSBERRYFARM_EXTRASHOWCATEGORYTYPES +KNOTTSBERRYFARM_EXTRARESTAURANTCATEGORYTYPES +KNOTTSBERRYFARM_ATTRACTIONCATEGORIES +KNOTTSBERRYFARM_SHOWCATEGORIES +KNOTTSBERRYFARM_DININGCATEGORIES +CALIFORNIASGREATAMERICA_PARKID +CALIFORNIASGREATAMERICA_DESTINATIONID +CALIFORNIASGREATAMERICA_BASEURL +CALIFORNIASGREATAMERICA_REALTIMEBASEURL +CALIFORNIASGREATAMERICA_CONFIGPATH +CALIFORNIASGREATAMERICA_LONGITUDE +CALIFORNIASGREATAMERICA_LATITUDE +CALIFORNIASGREATAMERICA_EXTRAATTRACTIONCATEGORYTYPES +CALIFORNIASGREATAMERICA_EXTRASHOWCATEGORYTYPES +CALIFORNIASGREATAMERICA_EXTRARESTAURANTCATEGORYTYPES +CALIFORNIASGREATAMERICA_ATTRACTIONCATEGORIES +CALIFORNIASGREATAMERICA_SHOWCATEGORIES +CALIFORNIASGREATAMERICA_DININGCATEGORIES +CANADASWONDERLAND_PARKID +CANADASWONDERLAND_DESTINATIONID +CANADASWONDERLAND_BASEURL +CANADASWONDERLAND_REALTIMEBASEURL +CANADASWONDERLAND_CONFIGPATH +CANADASWONDERLAND_LONGITUDE +CANADASWONDERLAND_LATITUDE +CANADASWONDERLAND_EXTRAATTRACTIONCATEGORYTYPES +CANADASWONDERLAND_EXTRASHOWCATEGORYTYPES +CANADASWONDERLAND_EXTRARESTAURANTCATEGORYTYPES +CANADASWONDERLAND_ATTRACTIONCATEGORIES +CANADASWONDERLAND_SHOWCATEGORIES +CANADASWONDERLAND_DININGCATEGORIES +CAROWINDS_PARKID +CAROWINDS_DESTINATIONID +CAROWINDS_BASEURL +CAROWINDS_REALTIMEBASEURL +CAROWINDS_CONFIGPATH +CAROWINDS_LONGITUDE +CAROWINDS_LATITUDE +CAROWINDS_EXTRAATTRACTIONCATEGORYTYPES +CAROWINDS_EXTRASHOWCATEGORYTYPES +CAROWINDS_EXTRARESTAURANTCATEGORYTYPES +CAROWINDS_ATTRACTIONCATEGORIES +CAROWINDS_SHOWCATEGORIES +CAROWINDS_DININGCATEGORIES +KINGSISLAND_PARKID +KINGSISLAND_DESTINATIONID +KINGSISLAND_BASEURL +KINGSISLAND_REALTIMEBASEURL +KINGSISLAND_CONFIGPATH +KINGSISLAND_LONGITUDE +KINGSISLAND_LATITUDE +KINGSISLAND_EXTRAATTRACTIONCATEGORYTYPES +KINGSISLAND_EXTRASHOWCATEGORYTYPES +KINGSISLAND_EXTRARESTAURANTCATEGORYTYPES +KINGSISLAND_ATTRACTIONCATEGORIES +KINGSISLAND_SHOWCATEGORIES +KINGSISLAND_DININGCATEGORIES +DORNEYPARK_PARKID +DORNEYPARK_DESTINATIONID +DORNEYPARK_CONFIGPATH +DORNEYPARK_BASEURL +DORNEYPARK_REALTIMEBASEURL +DORNEYPARK_LONGITUDE +DORNEYPARK_LATITUDE +DORNEYPARK_EXTRAATTRACTIONCATEGORYTYPES +DORNEYPARK_EXTRASHOWCATEGORYTYPES +DORNEYPARK_EXTRARESTAURANTCATEGORYTYPES +DORNEYPARK_ATTRACTIONCATEGORIES +DORNEYPARK_SHOWCATEGORIES +DORNEYPARK_DININGCATEGORIES +KINGSDOMINION_PARKID +KINGSDOMINION_DESTINATIONID +KINGSDOMINION_CONFIGPATH +KINGSDOMINION_BASEURL +KINGSDOMINION_REALTIMEBASEURL +KINGSDOMINION_LONGITUDE +KINGSDOMINION_LATITUDE +KINGSDOMINION_EXTRAATTRACTIONCATEGORYTYPES +KINGSDOMINION_EXTRASHOWCATEGORYTYPES +KINGSDOMINION_EXTRARESTAURANTCATEGORYTYPES +KINGSDOMINION_ATTRACTIONCATEGORIES +KINGSDOMINION_SHOWCATEGORIES +KINGSDOMINION_DININGCATEGORIES +MICHIGANSADVENTURE_PARKID +MICHIGANSADVENTURE_DESTINATIONID +MICHIGANSADVENTURE_CONFIGPATH +MICHIGANSADVENTURE_BASEURL +MICHIGANSADVENTURE_REALTIMEBASEURL +MICHIGANSADVENTURE_LONGITUDE +MICHIGANSADVENTURE_LATITUDE +MICHIGANSADVENTURE_EXTRAATTRACTIONCATEGORYTYPES +MICHIGANSADVENTURE_EXTRASHOWCATEGORYTYPES +MICHIGANSADVENTURE_EXTRARESTAURANTCATEGORYTYPES +MICHIGANSADVENTURE_ATTRACTIONCATEGORIES +MICHIGANSADVENTURE_SHOWCATEGORIES +MICHIGANSADVENTURE_DININGCATEGORIES +VALLEYFAIR_PARKID +VALLEYFAIR_DESTINATIONID +VALLEYFAIR_CONFIGPATH +VALLEYFAIR_BASEURL +VALLEYFAIR_REALTIMEBASEURL +VALLEYFAIR_LONGITUDE +VALLEYFAIR_LATITUDE +VALLEYFAIR_EXTRAATTRACTIONCATEGORYTYPES +VALLEYFAIR_EXTRASHOWCATEGORYTYPES +VALLEYFAIR_EXTRARESTAURANTCATEGORYTYPES +VALLEYFAIR_ATTRACTIONCATEGORIES +VALLEYFAIR_SHOWCATEGORIES +VALLEYFAIR_DININGCATEGORIES +WORLDSOFFUN_PARKID +WORLDSOFFUN_DESTINATIONID +WORLDSOFFUN_CONFIGPATH +WORLDSOFFUN_BASEURL +WORLDSOFFUN_REALTIMEBASEURL +WORLDSOFFUN_LONGITUDE +WORLDSOFFUN_LATITUDE +WORLDSOFFUN_EXTRAATTRACTIONCATEGORYTYPES +WORLDSOFFUN_EXTRASHOWCATEGORYTYPES +WORLDSOFFUN_EXTRARESTAURANTCATEGORYTYPES +WORLDSOFFUN_ATTRACTIONCATEGORIES +WORLDSOFFUN_SHOWCATEGORIES +WORLDSOFFUN_DININGCATEGORIES +HERSHEYPARK_APIKEY +HERSHEYPARK_BASEURL +SIXFLAGS_BASEURL +SIXFLAGS_AUTHHEADER +HANSAPARK_RESORTID +HANSAPARK_BASEURL +HANSAPARK_LOCALE +HANSAPARK_APIKEY +KNOEBELS_DESTINATIONID +KNOEBELS_PARKID +KNOEBELS_INITIALDATAVERSION +KNOEBELS_APPBUILD +KNOEBELS_APPVERSION +KNOEBELS_BASEURL +KNOEBELS_DEVICEIDENTIFIER +KNOEBELS_APIKEY +KNOEBELS_CALENDARURL +WALIBIBELGIUM_APIKEY +WALIBIBELGIUM_BASEURL +WALIBIBELGIUM_DESTINATIONSLUG +WALIBIBELGIUM_PARKSLUG +WALIBIBELGIUM_APISHORTCODE +WALIBIBELGIUM_CULTURE ``` - -Create a `.env` file in the project root. Some destinations share configuration via prefixes (e.g., `ATTRACTIONSIO_BASEURL` applies to all Attractions.io parks). - -Run `npm run dev -- -v` to see which config properties a destination expects. - -## Architecture - -The library uses a **decorator-based design** with TypeScript: - -- **`@destinationController`** — Auto-registers destinations, applies config proxy -- **`@config`** — Property-level config injection from env vars -- **`@http`** — Queue-based HTTP with retry, caching, validation -- **`@inject`** — Event-based dependency injection (auth headers, response transforms) -- **`@cache`** — SQLite-backed caching with TTL - -All parks extend the `Destination` base class using the **Template Method Pattern** — implement `buildEntityList()`, `buildLiveData()`, and `buildSchedules()`. - -See `CLAUDE.md` for full architecture documentation. - -## Contributing - -Contributions are welcome. To add a new destination: - -1. Create `src/parks//.ts` extending `Destination` -2. Implement entity, live data, and schedule methods -3. Test with `npm run dev -- ` -4. Submit a PR - -See `CLAUDE.md` and `.claude/skills/implementing-parks.md` for detailed implementation guidance. - -## Support - -General support is available for the [ThemeParks.wiki API](https://themeparks.wiki). This source code is self-service (sponsors get support benefits). - -## API Documentation - -[https://themeparks.github.io/parksapi/](https://themeparks.github.io/parksapi/) + diff --git a/TODO.MD b/TODO.MD index cd199b6f6..ca2286549 100644 --- a/TODO.MD +++ b/TODO.MD @@ -27,8 +27,9 @@ - Universal Studios Singapore (USS) - Lotte World - Chimelong: Guangzhou + Zhuhai (2 destinations) +- Ocean Park Hong Kong: Auth token, coordinate affine transform, showtimes, park schedule -**Totals: ~118 destinations, 1068 tests, 45 test files** +**Totals: ~119 destinations, 1068 tests, 45 test files** ## Remaining Migrations diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts new file mode 100644 index 000000000..08ed73c7c --- /dev/null +++ b/src/parks/oceanpark/oceanpark.ts @@ -0,0 +1,661 @@ +/** + * Ocean Park Hong Kong + * + * Single destination with one park. Attractions, shows, and dining are fetched + * from a mobile API (sop.oceanpark.com.hk) that requires a short-lived bearer + * token ("optoken") in each request header. + * + * Coordinate data: The park app exposes a map at map.oceanpark.com.hk with + * entity pixel positions. Reference points (pixel → lat/lng anchors) are + * fetched and used to compute an affine transform so all entity coordinates + * can be derived from their pixel positions. + */ + +import crypto from 'crypto'; +import {Destination, DestinationConstructor} from '../../destination.js'; +import config from '../../config.js'; +import {cache} from '../../cache.js'; +import {http, HTTPObj} from '../../http.js'; +import {inject} from '../../injector.js'; +import {destinationController} from '../../destinationRegistry.js'; +import {hostnameFromUrl, formatInTimezone, formatDate} from '../../datetime.js'; +import {TagBuilder} from '../../tags/index.js'; +import type {Entity, LiveData, EntitySchedule} from '@themeparks/typelib'; +import {AttractionTypeEnum} from '@themeparks/typelib'; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const TIMEZONE = 'Asia/Hong_Kong'; +const DESTINATION_ID = 'oceanparkresort'; +const PARK_ID = 'oceanpark'; +const DEFAULT_LAT = 22.2465; +const DEFAULT_LNG = 114.1748; + +/** Ocean Park entity sort IDs */ +const SORT_ID = { + TRANSPORT: 7, + RIDES: 8, + SHOWS: 15, + DINING: 17, +} as const; + +/** Map category slugs that contain entity pixel positions */ +const MAP_CATEGORIES = ['attractions', 'animals', 'dining', 'transportations', 'shows', 'shops'] as const; + +// ── API Interfaces ────────────────────────────────────────────────────────── + +interface OceanParkTokenResponse { + data?: { + token?: string; + tokenExpire?: number; // Unix ms expiry + }; +} + +interface OceanParkCondition { + conditionDesc?: string; + description?: string; +} + +interface OceanParkOperatingHour { + openDate: string; // 'YYYY-MM-DD' + openTime?: number; // Unix ms + closeTime?: number; // Unix ms +} + +interface OceanParkPflowInfo { + entityStatus?: string; // 'open' | 'close' | etc. + entityWaitTime?: number | null; + operatingHourList?: OceanParkOperatingHour[]; +} + +interface OceanParkEntity { + id: number; + name: string; + typeId?: number; + extEntityCode?: string | number; + conditionList?: Array; + raFacilityType?: string; + pflowInfo?: OceanParkPflowInfo; +} + +interface OceanParkEntityListResponse { + data?: { + data?: OceanParkEntity[]; + }; +} + +interface OceanParkTimeSlot { + startTime: number; // Unix ms + endTime: number; // Unix ms +} + +interface OceanParkActivity { + timeList?: OceanParkTimeSlot[]; +} + +interface OceanParkEntityDetail { + relateList?: Array<{type: string; [key: string]: unknown}>; + activityList?: OceanParkActivity[]; +} + +interface OceanParkEntityDetailResponse { + data?: OceanParkEntityDetail; +} + +interface OceanParkParkDay { + openDate: string; // 'YYYY-MM-DD' + parkStatus: string; // 'open' | 'close' | etc. + parkOpenTime?: string; // Unix ms as string + parkCloseTime?: string; // Unix ms as string + parkingOpenTime?: string; + parkingCloseTime?: string; + summitStaus?: string; // Note: typo in API + summitCloseTime?: string; +} + +interface OceanParkScheduleResponse { + data?: { + parkOperatingHourList?: OceanParkParkDay[]; + }; +} + +interface OceanParkReferencePoint { + pixelX: number; + pixelY: number; + latitude: number; + longitude: number; +} + +interface OceanParkMapEntity { + api_key?: string | number; + x?: number; + y?: number; +} + +interface AffineCoeffs { + a: number; b: number; c: number; // lat = a*x + b*y + c + d: number; e: number; f: number; // lng = d*x + e*y + f +} + +// ── Pure Functions ────────────────────────────────────────────────────────── + +/** + * Compute affine transform coefficients from a set of reference points. + * Solves lat = a*x + b*y + c and lng = d*x + e*y + f using least-squares + * normal equations (Cramer's rule on the 3×3 system). + */ +function computeAffineTransform(refPoints: OceanParkReferencePoint[]): AffineCoeffs { + let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, sumYY = 0; + let sumLat = 0, sumXLat = 0, sumYLat = 0; + let sumLng = 0, sumXLng = 0, sumYLng = 0; + const n = refPoints.length; + + for (const p of refPoints) { + const {pixelX: x, pixelY: y, latitude: lat, longitude: lng} = p; + sumX += x; sumY += y; + sumXX += x * x; sumXY += x * y; sumYY += y * y; + sumLat += lat; sumXLat += x * lat; sumYLat += y * lat; + sumLng += lng; sumXLng += x * lng; sumYLng += y * lng; + } + + const M: [number, number, number][] = [ + [sumXX, sumXY, sumX], + [sumXY, sumYY, sumY], + [sumX, sumY, n], + ]; + + const det = (m: [number, number, number][]) => + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - + m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + + const D = det(M); + + const cramer = (rhs: number[]): [number, number, number] => { + const M0: [number, number, number][] = [[rhs[0], M[0][1], M[0][2]], [rhs[1], M[1][1], M[1][2]], [rhs[2], M[2][1], M[2][2]]]; + const M1: [number, number, number][] = [[M[0][0], rhs[0], M[0][2]], [M[1][0], rhs[1], M[1][2]], [M[2][0], rhs[2], M[2][2]]]; + const M2: [number, number, number][] = [[M[0][0], M[0][1], rhs[0]], [M[1][0], M[1][1], rhs[1]], [M[2][0], M[2][1], rhs[2]]]; + return [det(M0) / D, det(M1) / D, det(M2) / D]; + }; + + const [a, b, c] = cramer([sumXLat, sumYLat, sumLat]); + const [d, e, f] = cramer([sumXLng, sumYLng, sumLng]); + return {a, b, c, d, e, f}; +} + +/** + * Parse height restriction values from a conditionList. + * Supports patterns: "Height: 140cm" (min) and "Between 100cm and 140cm" (max). + */ +function parseHeightTag(conditionList: Array): {min: number | null; max: number | null} { + let min: number | null = null; + let max: number | null = null; + + for (const cond of conditionList) { + const text = typeof cond === 'string' ? cond : (cond.conditionDesc ?? cond.description ?? ''); + + const minMatch = text.match(/Height:\s*(\d+)\s*cm/i); + if (minMatch) min = parseInt(minMatch[1], 10); + + const maxMatch = text.match(/Between\s*\d+\s*cm.*?and\s*(\d+)\s*cm/i); + if (maxMatch) max = parseInt(maxMatch[1], 10); + } + + return {min, max}; +} + +// ── Implementation ────────────────────────────────────────────────────────── + +@destinationController({category: 'Ocean Park'}) +@config +export class OceanParkHongKong extends Destination { + @config baseURL: string = 'https://sop.oceanpark.com.hk'; + @config mapURL: string = 'https://map.oceanpark.com.hk'; + @config parkId: number = 1; + + timezone = TIMEZONE; + + constructor(options?: DestinationConstructor) { + super(options); + this.addConfigPrefix('OCEANPARK'); + } + + getCacheKeyPrefix(): string { + return 'oceanpark'; + } + + // ── Initialisation ──────────────────────────────────────────────────────── + + /** Pre-warm the token cache before entity/live data calls fire in parallel. */ + protected async _init(): Promise { + await this.getToken(); + } + + // ── Authentication ──────────────────────────────────────────────────────── + + /** + * Stable device UUID — generated once, persisted in SQLite for 3 months. + * Ocean Park's API uses this to associate tokens with a logical device. + */ + @cache({ttlSeconds: 60 * 60 * 24 * 90}) + async getDeviceId(): Promise { + return crypto.randomUUID(); + } + + /** Raw HTTP call to the token endpoint — tagged 'auth' to exclude from injection. */ + @http({tags: ['auth']} as any) + async fetchToken(): Promise { + const deviceId = await this.getDeviceId(); + return { + method: 'POST', + url: `${this.baseURL}/api/common/user/token`, + body: JSON.stringify({pId: this.parkId, lang: 'en', deviceId}), + headers: {'content-type': 'application/json'}, + options: {json: false}, + tags: ['auth'], + } as any as HTTPObj; + } + + /** + * Auth token with dynamic TTL. + * Returns an object with `token` + `ttl` so @cache can read the expiry. + * Use getToken() to obtain just the token string. + */ + @cache({callback: (result: {token: string; ttl: number}) => result.ttl}) + async getTokenData(): Promise<{token: string; ttl: number}> { + const resp = await this.fetchToken(); + const body: OceanParkTokenResponse = await resp.json(); + const token = body?.data?.token; + const tokenExpire = body?.data?.tokenExpire; + + if (!token) throw new Error('OceanPark: failed to obtain auth token'); + + const ttl = tokenExpire + ? Math.max((tokenExpire - Date.now()) / 1000, 60) + : 60 * 60 * 23; + + return {token, ttl}; + } + + /** Returns the current valid auth token. */ + async getToken(): Promise { + return (await this.getTokenData()).token; + } + + /** + * Inject the optoken header into every request to the main API domain, + * except for the token endpoint itself (excluded via tags filter). + */ + @inject({ + eventName: 'httpRequest', + hostname: function(this: OceanParkHongKong) { return hostnameFromUrl(this.baseURL); }, + tags: {$nin: ['auth']}, + } as any) + async injectToken(req: HTTPObj): Promise { + const token = await this.getToken(); + req.headers = { + ...req.headers, + 'optoken': token, + 'content-type': 'application/json', + }; + } + + // ── HTTP Fetch Methods ──────────────────────────────────────────────────── + + /** + * Fetch the entity list for a given sortId. + * sortId 7 = transport, 8 = rides, 15 = shows, 17 = dining. + * Short cache (60s) since this also carries live wait-time data. + */ + @http({cacheSeconds: 60} as any) + async fetchEntityList(sortId: number): Promise { + return { + method: 'POST', + url: `${this.baseURL}/api/common/entity/list`, + body: JSON.stringify({pId: this.parkId, lang: 'en', sortId}), + options: {json: false}, + } as any as HTTPObj; + } + + /** + * Fetch detailed info for a single entity (FastPass links, show schedule). + * Long cache (1h) since this data changes infrequently. + */ + @http({cacheSeconds: 3600} as any) + async fetchEntityDetail(entityId: number): Promise { + return { + method: 'POST', + url: `${this.baseURL}/api/common/entity/detail`, + body: JSON.stringify({pId: this.parkId, lang: 'en', entityId}), + options: {json: false}, + } as any as HTTPObj; + } + + /** Fetch 30-day park operating schedule. Refreshed every hour. */ + @http({cacheSeconds: 3600} as any) + async fetchParkSchedule(): Promise { + const today = formatDate(new Date(), TIMEZONE); + const end = formatDate(new Date(Date.now() + 30 * 24 * 3600 * 1000), TIMEZONE); + return { + method: 'POST', + url: `${this.baseURL}/api/common/park/list`, + body: JSON.stringify({pId: this.parkId, lang: 'en', startDate: today, endDate: end}), + options: {json: false}, + } as any as HTTPObj; + } + + /** Fetch reference points (pixel → lat/lng anchors) from the map subdomain. */ + @http({cacheSeconds: 86400} as any) + async fetchReferencePoints(): Promise { + return { + method: 'GET', + url: `${this.mapURL}/assets/data/reference_points.json`, + options: {json: true}, + } as any as HTTPObj; + } + + /** Fetch entity pixel positions for a given map category. */ + @http({cacheSeconds: 86400} as any) + async fetchMapCategoryData(category: string): Promise { + return { + method: 'GET', + url: `${this.mapURL}/assets/data/${category}.json`, + options: {json: true}, + } as any as HTTPObj; + } + + // ── Cached Accessors ────────────────────────────────────────────────────── + + @cache({ttlSeconds: 60}) + async getEntityList(sortId: number): Promise { + const resp = await this.fetchEntityList(sortId); + const body: OceanParkEntityListResponse = await resp.json(); + return body?.data?.data ?? []; + } + + @cache({ttlSeconds: 3600}) + async getEntityDetail(entityId: number): Promise { + const resp = await this.fetchEntityDetail(entityId); + const body: OceanParkEntityDetailResponse = await resp.json(); + return body?.data ?? {}; + } + + @cache({ttlSeconds: 3600}) + async getParkSchedule(): Promise { + const resp = await this.fetchParkSchedule(); + const body: OceanParkScheduleResponse = await resp.json(); + return body?.data?.parkOperatingHourList ?? []; + } + + /** + * Build a serialisable map from api_key → {latitude, longitude} by: + * 1. Fetching reference points and computing an affine pixel→geo transform. + * 2. Fetching each map category and projecting each entity's pixel position. + * + * Returned as an array of [key, value] pairs so @cache can serialise it. + * Cached for 24 hours — map data is essentially static. + */ + @cache({ttlSeconds: 86400}) + async getCoordinateMapEntries(): Promise<[string, {latitude: number; longitude: number}][]> { + const refResp = await this.fetchReferencePoints(); + const refPoints: OceanParkReferencePoint[] = await refResp.json(); + if (!Array.isArray(refPoints) || refPoints.length < 3) return []; + + const coeffs = computeAffineTransform(refPoints); + const entries: [string, {latitude: number; longitude: number}][] = []; + + for (const category of MAP_CATEGORIES) { + const resp = await this.fetchMapCategoryData(category); + const entities: OceanParkMapEntity[] = await resp.json(); + if (!Array.isArray(entities)) continue; + + for (const e of entities) { + if (e.api_key != null && e.x != null && e.y != null) { + entries.push([ + String(e.api_key), + { + latitude: coeffs.a * e.x + coeffs.b * e.y + coeffs.c, + longitude: coeffs.d * e.x + coeffs.e * e.y + coeffs.f, + }, + ]); + } + } + } + + return entries; + } + + // ── Destination ─────────────────────────────────────────────────────────── + + async getDestinations(): Promise { + return [{ + id: DESTINATION_ID, + name: 'Ocean Park Hong Kong', + entityType: 'DESTINATION', + timezone: TIMEZONE, + location: {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity]; + } + + // ── Entity List ─────────────────────────────────────────────────────────── + + protected async buildEntityList(): Promise { + const [rides, transport, shows, dining, coordEntries] = await Promise.all([ + this.getEntityList(SORT_ID.RIDES), + this.getEntityList(SORT_ID.TRANSPORT), + this.getEntityList(SORT_ID.SHOWS), + this.getEntityList(SORT_ID.DINING), + this.getCoordinateMapEntries(), + ]); + + const coordMap = new Map(coordEntries); + + const park: Entity = { + id: PARK_ID, + name: 'Ocean Park', + entityType: 'PARK', + parentId: DESTINATION_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + + // Fetch details for rides + transport to check for FastPass (relateList) + const attractions = [...rides, ...transport]; + const details = await Promise.all( + attractions.map(e => this.getEntityDetail(e.id).catch(() => ({} as OceanParkEntityDetail))), + ); + + const attractionEntities: Entity[] = attractions.map((entity, i) => { + const isTransport = entity.typeId === SORT_ID.TRANSPORT; + const coords = coordMap.get(String(entity.extEntityCode)); + const detail = details[i]; + const tags = []; + + if (coords) { + tags.push(TagBuilder.location(coords.latitude, coords.longitude, 'Attraction Location')); + } + + const conditionList = entity.conditionList ?? []; + const {min, max} = parseHeightTag(conditionList); + if (min !== null) tags.push(TagBuilder.minimumHeight(min, 'cm')); + if (max !== null) tags.push(TagBuilder.maximumHeight(max, 'cm')); + + const hasPregnantWarning = conditionList.some(c => { + const text = typeof c === 'string' ? c : (c.conditionDesc ?? c.description ?? ''); + return /pregnant/i.test(text); + }); + if (hasPregnantWarning) tags.push(TagBuilder.unsuitableForPregnantPeople()); + + if (entity.raFacilityType === 'Wet Rides') tags.push(TagBuilder.mayGetWet()); + + const hasFastPass = Array.isArray(detail?.relateList) && + detail.relateList.some(r => r.type === 'ticket'); + if (hasFastPass) tags.push(TagBuilder.paidReturnTime()); + + const built: Entity = { + id: `attraction_${entity.id}`, + name: entity.name, + entityType: 'ATTRACTION', + attractionType: isTransport ? AttractionTypeEnum.TRANSPORT : AttractionTypeEnum.RIDE, + parentId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: coords ?? {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + + if (tags.length > 0) built.tags = tags; + return built; + }); + + const showEntities: Entity[] = shows.map(entity => { + const coords = coordMap.get(String(entity.extEntityCode)); + return { + id: `show_${entity.id}`, + name: entity.name, + entityType: 'SHOW', + parentId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: coords ?? {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + }); + + const restaurantEntities: Entity[] = dining.map(entity => { + const coords = coordMap.get(String(entity.extEntityCode)); + return { + id: `restaurant_${entity.id}`, + name: entity.name, + entityType: 'RESTAURANT', + parentId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: coords ?? {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + }); + + return [park, ...attractionEntities, ...showEntities, ...restaurantEntities]; + } + + // ── Live Data ───────────────────────────────────────────────────────────── + + protected async buildLiveData(): Promise { + const today = formatDate(new Date(), TIMEZONE); + + const [rides, transport, shows] = await Promise.all([ + this.getEntityList(SORT_ID.RIDES), + this.getEntityList(SORT_ID.TRANSPORT), + this.getEntityList(SORT_ID.SHOWS), + ]); + + const liveData: LiveData[] = []; + + // Rides and transport — include wait time and today's operating hours when open + for (const entity of [...rides, ...transport]) { + const pflow = entity.pflowInfo ?? {}; + const isOpen = pflow.entityStatus === 'open'; + const waitTime = pflow.entityWaitTime; + + const ld: LiveData = { + id: `attraction_${entity.id}`, + status: isOpen ? 'OPERATING' : 'CLOSED', + } as LiveData; + + if (isOpen && waitTime != null && waitTime >= 0) { + ld.queue = {STANDBY: {waitTime}}; + } + + const todayHours = (pflow.operatingHourList ?? []).find( + h => h.openDate === today && h.openTime && h.closeTime, + ); + if (todayHours) { + ld.operatingHours = [{ + type: 'Operating', + startTime: new Date(todayHours.openTime!).toISOString(), + endTime: new Date(todayHours.closeTime!).toISOString(), + }]; + } + + liveData.push(ld); + } + + // Shows — include showtimes from entity detail activityList + const showDetails = await Promise.all( + shows.map(e => this.getEntityDetail(e.id).catch(() => ({} as OceanParkEntityDetail))), + ); + + for (let i = 0; i < shows.length; i++) { + const entity = shows[i]; + const isOpen = entity.pflowInfo?.entityStatus === 'open'; + const detail = showDetails[i]; + + const showtimes = (detail.activityList ?? []).flatMap(activity => + (activity.timeList ?? []).map(t => ({ + type: 'Performance Time', + startTime: new Date(t.startTime).toISOString(), + endTime: new Date(t.endTime).toISOString(), + })), + ); + + const ld: LiveData = { + id: `show_${entity.id}`, + status: isOpen ? 'OPERATING' : 'CLOSED', + } as LiveData; + + if (showtimes.length > 0) ld.showtimes = showtimes; + + liveData.push(ld); + } + + return liveData; + } + + // ── Schedules ───────────────────────────────────────────────────────────── + + protected async buildSchedules(): Promise { + const parkDays = await this.getParkSchedule(); + const scheduleEntries: object[] = []; + + for (const day of parkDays) { + if (day.parkStatus !== 'open') continue; + if (!day.parkOpenTime || !day.parkCloseTime) continue; + + scheduleEntries.push({ + date: day.openDate, + type: 'OPERATING', + openingTime: formatInTimezone(new Date(Number(day.parkOpenTime)), TIMEZONE, 'iso'), + closingTime: formatInTimezone(new Date(Number(day.parkCloseTime)), TIMEZONE, 'iso'), + }); + + if (day.parkingOpenTime && day.parkingCloseTime) { + scheduleEntries.push({ + date: day.openDate, + type: 'INFORMATIONAL', + description: 'Parking', + openingTime: formatInTimezone(new Date(Number(day.parkingOpenTime)), TIMEZONE, 'iso'), + closingTime: formatInTimezone(new Date(Number(day.parkingCloseTime)), TIMEZONE, 'iso'), + }); + } + + // The Summit zone closes earlier than the main park on some days + if ( + day.summitStaus === 'open' && + day.summitCloseTime && + Number(day.summitCloseTime) < Number(day.parkCloseTime) + ) { + scheduleEntries.push({ + date: day.openDate, + type: 'INFORMATIONAL', + description: 'The Summit', + openingTime: formatInTimezone(new Date(Number(day.parkOpenTime)), TIMEZONE, 'iso'), + closingTime: formatInTimezone(new Date(Number(day.summitCloseTime)), TIMEZONE, 'iso'), + }); + } + } + + return [{ + id: PARK_ID, + schedule: scheduleEntries, + } as unknown as EntitySchedule]; + } +} From 5b210253b193f877c6b3bff1c47802c80b1d6b0d Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Thu, 30 Apr 2026 07:56:33 +0000 Subject: [PATCH 2/6] fix(oceanpark): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard affine transform against degenerate reference points (determinant ≈ 0 → return null, caller returns empty coords) - Remove duplicate @config class decorator (already applied by @destinationController) - Parallelize map category fetches with Promise.all Co-Authored-By: Claude Opus 4.6 (1M context) --- src/parks/oceanpark/oceanpark.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts index 08ed73c7c..966e438ee 100644 --- a/src/parks/oceanpark/oceanpark.ts +++ b/src/parks/oceanpark/oceanpark.ts @@ -144,7 +144,7 @@ interface AffineCoeffs { * Solves lat = a*x + b*y + c and lng = d*x + e*y + f using least-squares * normal equations (Cramer's rule on the 3×3 system). */ -function computeAffineTransform(refPoints: OceanParkReferencePoint[]): AffineCoeffs { +function computeAffineTransform(refPoints: OceanParkReferencePoint[]): AffineCoeffs | null { let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, sumYY = 0; let sumLat = 0, sumXLat = 0, sumYLat = 0; let sumLng = 0, sumXLng = 0, sumYLng = 0; @@ -170,6 +170,7 @@ function computeAffineTransform(refPoints: OceanParkReferencePoint[]): AffineCoe m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); const D = det(M); + if (!Number.isFinite(D) || Math.abs(D) < 1e-10) return null; const cramer = (rhs: number[]): [number, number, number] => { const M0: [number, number, number][] = [[rhs[0], M[0][1], M[0][2]], [rhs[1], M[1][1], M[1][2]], [rhs[2], M[2][1], M[2][2]]]; @@ -207,7 +208,6 @@ function parseHeightTag(conditionList: Array): {min // ── Implementation ────────────────────────────────────────────────────────── @destinationController({category: 'Ocean Park'}) -@config export class OceanParkHongKong extends Destination { @config baseURL: string = 'https://sop.oceanpark.com.hk'; @config mapURL: string = 'https://map.oceanpark.com.hk'; @@ -402,10 +402,13 @@ export class OceanParkHongKong extends Destination { if (!Array.isArray(refPoints) || refPoints.length < 3) return []; const coeffs = computeAffineTransform(refPoints); + if (!coeffs) return []; const entries: [string, {latitude: number; longitude: number}][] = []; - for (const category of MAP_CATEGORIES) { - const resp = await this.fetchMapCategoryData(category); + const categoryResponses = await Promise.all( + MAP_CATEGORIES.map((category) => this.fetchMapCategoryData(category)), + ); + for (const resp of categoryResponses) { const entities: OceanParkMapEntity[] = await resp.json(); if (!Array.isArray(entities)) continue; From 07cd33d4760a2a9af6c6a1c50e2b33ebcb45ea05 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Thu, 30 Apr 2026 16:17:56 +0000 Subject: [PATCH 3/6] fix(oceanpark): RFC 3339 schedule format, env-driven URLs, finite-number guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced when reviewing the output: 1. **Schedule timestamps were `2026-05-01T10:00:00GMT+8`** instead of `+08:00`. The pre-existing `formatInTimezone(... 'iso')` helper passed `Intl.DateTimeFormat`'s `shortOffset` value through verbatim, but for timezones like Asia/Hong_Kong the API returns `GMT+8` rather than the RFC 3339 `+HH:MM` form. Other parks dodge this by routing through `constructDateTime`, which has its own GMT-style normalizer (lines 183-191 of datetime.ts). Move the same normalization into `formatInTimezone` so callers always get a valid ISO 8601 string. 2. **Hardcoded URL defaults** in `@config baseURL` / `@config mapURL` violated the project convention (CLAUDE.md: "no hardcoded URLs/secrets; all in @config with empty defaults, loaded from .env"). Set both to `''`; configuration is via `OCEANPARK_BASEURL` / `OCEANPARK_MAPURL` env vars per the project's standard config-prefix mechanism. 3. **`Number()` coercion on schedule timestamps** could silently produce `NaN` → `Invalid Date` for malformed `parkOpenTime` / `parkCloseTime` / `parkingOpenTime` / `parkingCloseTime` / `summitCloseTime`. Add a `parseTs` helper that uses `Number.isFinite()` (per CLAUDE.md numeric- validation guidance) and returns null for non-finite values; skip the day or sub-block when any required timestamp fails to parse. Coordinate transform was not dead code as initially feared — after a fresh cache, ~50 of 94 entities receive real coordinates (the rest are newer attractions not yet present in the static map JSONs and fall back to the destination centroid). No change required there. Also updates one DST-transition test that asserted on the legacy `GMT-5` / `GMT-4` substring; relaxed to accept the new canonical `-05:00` / `-04:00` form alongside the old shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parks/oceanpark/oceanpark.ts | 37 +++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts index 966e438ee..733590297 100644 --- a/src/parks/oceanpark/oceanpark.ts +++ b/src/parks/oceanpark/oceanpark.ts @@ -209,8 +209,8 @@ function parseHeightTag(conditionList: Array): {min @destinationController({category: 'Ocean Park'}) export class OceanParkHongKong extends Destination { - @config baseURL: string = 'https://sop.oceanpark.com.hk'; - @config mapURL: string = 'https://map.oceanpark.com.hk'; + @config baseURL: string = ''; + @config mapURL: string = ''; @config parkId: number = 1; timezone = TIMEZONE; @@ -619,39 +619,52 @@ export class OceanParkHongKong extends Destination { const parkDays = await this.getParkSchedule(); const scheduleEntries: object[] = []; + /** Parse a Unix-ms timestamp string to a Date, rejecting non-finite values. */ + const parseTs = (v: string | number | undefined | null): Date | null => { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? new Date(n) : null; + }; + for (const day of parkDays) { if (day.parkStatus !== 'open') continue; - if (!day.parkOpenTime || !day.parkCloseTime) continue; + + const open = parseTs(day.parkOpenTime); + const close = parseTs(day.parkCloseTime); + if (!open || !close) continue; scheduleEntries.push({ date: day.openDate, type: 'OPERATING', - openingTime: formatInTimezone(new Date(Number(day.parkOpenTime)), TIMEZONE, 'iso'), - closingTime: formatInTimezone(new Date(Number(day.parkCloseTime)), TIMEZONE, 'iso'), + openingTime: formatInTimezone(open, TIMEZONE, 'iso'), + closingTime: formatInTimezone(close, TIMEZONE, 'iso'), }); - if (day.parkingOpenTime && day.parkingCloseTime) { + const parkingOpen = parseTs(day.parkingOpenTime); + const parkingClose = parseTs(day.parkingCloseTime); + if (parkingOpen && parkingClose) { scheduleEntries.push({ date: day.openDate, type: 'INFORMATIONAL', description: 'Parking', - openingTime: formatInTimezone(new Date(Number(day.parkingOpenTime)), TIMEZONE, 'iso'), - closingTime: formatInTimezone(new Date(Number(day.parkingCloseTime)), TIMEZONE, 'iso'), + openingTime: formatInTimezone(parkingOpen, TIMEZONE, 'iso'), + closingTime: formatInTimezone(parkingClose, TIMEZONE, 'iso'), }); } // The Summit zone closes earlier than the main park on some days + const summitClose = parseTs(day.summitCloseTime); if ( day.summitStaus === 'open' && - day.summitCloseTime && - Number(day.summitCloseTime) < Number(day.parkCloseTime) + summitClose && + summitClose.getTime() < close.getTime() ) { scheduleEntries.push({ date: day.openDate, type: 'INFORMATIONAL', description: 'The Summit', - openingTime: formatInTimezone(new Date(Number(day.parkOpenTime)), TIMEZONE, 'iso'), - closingTime: formatInTimezone(new Date(Number(day.summitCloseTime)), TIMEZONE, 'iso'), + openingTime: formatInTimezone(open, TIMEZONE, 'iso'), + closingTime: formatInTimezone(summitClose, TIMEZONE, 'iso'), }); } } From 52b784d73dc43402b7006800826ff3b9d5a4a678 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 31 May 2026 20:10:16 +0000 Subject: [PATCH 4/6] fix(oceanpark): apply 10 review findings from second-pass code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - waitTime: coerce + Number.isFinite() guard before emitting; the interface declares number|null but upstreams sometimes send strings. - operatingHours.type: 'Operating' → 'OPERATING' so it matches every other park's emission and any downstream string-match. - Live operatingHours / showtimes now use formatInTimezone(..., 'iso') instead of .toISOString() so timestamps carry the +08:00 offset consistently with buildSchedules. - Showtimes filter to t.endTime >= now; an 11am performance shouldn't appear as upcoming when checked at 3pm. Cache-poisoning (same shape as the Genting #196 fix): - getCoordinateMapEntries now throws on degenerate input instead of returning []. The @cache wrapper was caching the empty failure for 24h, pinning every entity to the destination's default lat/lng for a full day after one bad upstream response. Callers in buildEntityList catch and fall back to no-coords without poisoning. - Dropped @cache decorators on getEntityList / getEntityDetail / getParkSchedule — the underlying @http fetchers already cache the raw responses at the same TTL, so the @cache layer was just double-writing parsed payloads and amplifying the same empty-result-poisons-TTL pattern at smaller scale. Quality / polish: - Removed TagBuilder.location() call on attractions — the TagBuilder docs explicitly flag this as the wrong pattern when the coordinates are also the entity's own location (which they are). - Dropped 6 unnecessary `as any` casts on decorator option literals; only the `tags: ['auth']` cast remains (it's the one case the @http decorator's option type genuinely doesn't accept yet — annotated). - Summit zone: defensive fallback for the `summitStaus` (sic) typo in case upstream ever corrects it; clarified the description to make the "Summit opens with the park" assumption explicit since the API only exposes summitCloseTime, not summitOpenTime. All 1174 tests still pass; harness 4/4 on Ocean Park with the timestamp format and showtime-filter behaviour confirmed. Co-Authored-By: Claude Opus 4.7 --- src/parks/oceanpark/oceanpark.ts | 106 +++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 34 deletions(-) diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts index 733590297..0d62b49d6 100644 --- a/src/parks/oceanpark/oceanpark.ts +++ b/src/parks/oceanpark/oceanpark.ts @@ -243,6 +243,8 @@ export class OceanParkHongKong extends Destination { } /** Raw HTTP call to the token endpoint — tagged 'auth' to exclude from injection. */ + // `tags` isn't in the @http decorator's option type yet but flows through + // to the request object — cast is the localised escape. @http({tags: ['auth']} as any) async fetchToken(): Promise { const deviceId = await this.getDeviceId(); @@ -290,7 +292,7 @@ export class OceanParkHongKong extends Destination { eventName: 'httpRequest', hostname: function(this: OceanParkHongKong) { return hostnameFromUrl(this.baseURL); }, tags: {$nin: ['auth']}, - } as any) + }) async injectToken(req: HTTPObj): Promise { const token = await this.getToken(); req.headers = { @@ -307,7 +309,7 @@ export class OceanParkHongKong extends Destination { * sortId 7 = transport, 8 = rides, 15 = shows, 17 = dining. * Short cache (60s) since this also carries live wait-time data. */ - @http({cacheSeconds: 60} as any) + @http({cacheSeconds: 60}) async fetchEntityList(sortId: number): Promise { return { method: 'POST', @@ -321,7 +323,7 @@ export class OceanParkHongKong extends Destination { * Fetch detailed info for a single entity (FastPass links, show schedule). * Long cache (1h) since this data changes infrequently. */ - @http({cacheSeconds: 3600} as any) + @http({cacheSeconds: 3600}) async fetchEntityDetail(entityId: number): Promise { return { method: 'POST', @@ -332,7 +334,7 @@ export class OceanParkHongKong extends Destination { } /** Fetch 30-day park operating schedule. Refreshed every hour. */ - @http({cacheSeconds: 3600} as any) + @http({cacheSeconds: 3600}) async fetchParkSchedule(): Promise { const today = formatDate(new Date(), TIMEZONE); const end = formatDate(new Date(Date.now() + 30 * 24 * 3600 * 1000), TIMEZONE); @@ -345,7 +347,7 @@ export class OceanParkHongKong extends Destination { } /** Fetch reference points (pixel → lat/lng anchors) from the map subdomain. */ - @http({cacheSeconds: 86400} as any) + @http({cacheSeconds: 86400}) async fetchReferencePoints(): Promise { return { method: 'GET', @@ -355,7 +357,7 @@ export class OceanParkHongKong extends Destination { } /** Fetch entity pixel positions for a given map category. */ - @http({cacheSeconds: 86400} as any) + @http({cacheSeconds: 86400}) async fetchMapCategoryData(category: string): Promise { return { method: 'GET', @@ -364,23 +366,26 @@ export class OceanParkHongKong extends Destination { } as any as HTTPObj; } - // ── Cached Accessors ────────────────────────────────────────────────────── + // ── Parsed Accessors ────────────────────────────────────────────────────── + // + // No @cache wrapper here — the underlying @http fetcher already caches the + // raw response at the same TTL. Adding @cache on top would just double-write + // the parsed payload to SQLite. Failures still propagate from the fetch + // layer, so a transient bad upstream doesn't poison a parsed-empty result + // into the cache for a full TTL. - @cache({ttlSeconds: 60}) async getEntityList(sortId: number): Promise { const resp = await this.fetchEntityList(sortId); const body: OceanParkEntityListResponse = await resp.json(); return body?.data?.data ?? []; } - @cache({ttlSeconds: 3600}) async getEntityDetail(entityId: number): Promise { const resp = await this.fetchEntityDetail(entityId); const body: OceanParkEntityDetailResponse = await resp.json(); return body?.data ?? {}; } - @cache({ttlSeconds: 3600}) async getParkSchedule(): Promise { const resp = await this.fetchParkSchedule(); const body: OceanParkScheduleResponse = await resp.json(); @@ -392,17 +397,26 @@ export class OceanParkHongKong extends Destination { * 1. Fetching reference points and computing an affine pixel→geo transform. * 2. Fetching each map category and projecting each entity's pixel position. * - * Returned as an array of [key, value] pairs so @cache can serialise it. - * Cached for 24 hours — map data is essentially static. + * Returned as an array of [key, value] pairs. + * + * No @cache here — degenerate input must throw so the underlying @http + * fetchers (24h TTL each) keep retrying instead of pinning every entity + * to its default location for a day. Callers are expected to catch and + * fall back to no-coords on transient failure. */ - @cache({ttlSeconds: 86400}) async getCoordinateMapEntries(): Promise<[string, {latitude: number; longitude: number}][]> { const refResp = await this.fetchReferencePoints(); const refPoints: OceanParkReferencePoint[] = await refResp.json(); - if (!Array.isArray(refPoints) || refPoints.length < 3) return []; + if (!Array.isArray(refPoints) || refPoints.length < 3) { + throw new Error( + `OceanPark: reference points payload invalid (got ${Array.isArray(refPoints) ? `${refPoints.length} entries` : typeof refPoints})`, + ); + } const coeffs = computeAffineTransform(refPoints); - if (!coeffs) return []; + if (!coeffs) { + throw new Error('OceanPark: affine transform degenerate (collinear or duplicate reference points)'); + } const entries: [string, {latitude: number; longitude: number}][] = []; const categoryResponses = await Promise.all( @@ -448,7 +462,14 @@ export class OceanParkHongKong extends Destination { this.getEntityList(SORT_ID.TRANSPORT), this.getEntityList(SORT_ID.SHOWS), this.getEntityList(SORT_ID.DINING), - this.getCoordinateMapEntries(), + // Coordinates are best-effort — a degenerate transform or unreachable + // map subdomain shouldn't take the whole entity list with it. Entities + // fall back to the destination's default lat/lng when this is empty. + this.getCoordinateMapEntries().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[OceanPark] coordinate map unavailable (${msg}); entities will use default location`); + return [] as [string, {latitude: number; longitude: number}][]; + }), ]); const coordMap = new Map(coordEntries); @@ -475,10 +496,9 @@ export class OceanParkHongKong extends Destination { const detail = details[i]; const tags = []; - if (coords) { - tags.push(TagBuilder.location(coords.latitude, coords.longitude, 'Attraction Location')); - } - + // Coordinates already live on `entity.location` below — don't also + // push a TagBuilder.location() tag (per TagBuilder docs, that helper + // is for sub-locations distinct from the entity's own location). const conditionList = entity.conditionList ?? []; const {min, max} = parseHeightTag(conditionList); if (min !== null) tags.push(TagBuilder.minimumHeight(min, 'cm')); @@ -557,15 +577,18 @@ export class OceanParkHongKong extends Destination { for (const entity of [...rides, ...transport]) { const pflow = entity.pflowInfo ?? {}; const isOpen = pflow.entityStatus === 'open'; - const waitTime = pflow.entityWaitTime; const ld: LiveData = { id: `attraction_${entity.id}`, status: isOpen ? 'OPERATING' : 'CLOSED', } as LiveData; - if (isOpen && waitTime != null && waitTime >= 0) { - ld.queue = {STANDBY: {waitTime}}; + // Coerce + finite-check before emitting. The interface declares + // `number | null` but upstream APIs sometimes send strings; CLAUDE.md + // requires Number.isFinite over isNaN to handle empty-string coercion. + const wt = Number(pflow.entityWaitTime); + if (isOpen && Number.isFinite(wt) && wt >= 0) { + ld.queue = {STANDBY: {waitTime: wt}}; } const todayHours = (pflow.operatingHourList ?? []).find( @@ -573,9 +596,12 @@ export class OceanParkHongKong extends Destination { ); if (todayHours) { ld.operatingHours = [{ - type: 'Operating', - startTime: new Date(todayHours.openTime!).toISOString(), - endTime: new Date(todayHours.closeTime!).toISOString(), + // 'OPERATING' (uppercase) matches every other park's emission and + // any downstream string matchers. 'iso' format keeps the +08:00 + // offset consistent with buildSchedules below. + type: 'OPERATING', + startTime: formatInTimezone(new Date(todayHours.openTime!), TIMEZONE, 'iso'), + endTime: formatInTimezone(new Date(todayHours.closeTime!), TIMEZONE, 'iso'), }]; } @@ -587,17 +613,24 @@ export class OceanParkHongKong extends Destination { shows.map(e => this.getEntityDetail(e.id).catch(() => ({} as OceanParkEntityDetail))), ); + const now = Date.now(); for (let i = 0; i < shows.length; i++) { const entity = shows[i]; const isOpen = entity.pflowInfo?.entityStatus === 'open'; const detail = showDetails[i]; + // Filter out performances that have already ended today — a guest at + // 3pm shouldn't see this morning's 11am show listed as upcoming. + // Project timestamps via formatInTimezone so the emitted ISO strings + // carry the +08:00 offset (matches buildSchedules). const showtimes = (detail.activityList ?? []).flatMap(activity => - (activity.timeList ?? []).map(t => ({ - type: 'Performance Time', - startTime: new Date(t.startTime).toISOString(), - endTime: new Date(t.endTime).toISOString(), - })), + (activity.timeList ?? []) + .filter(t => Number.isFinite(t.startTime) && Number.isFinite(t.endTime) && t.endTime >= now) + .map(t => ({ + type: 'Performance Time', + startTime: formatInTimezone(new Date(t.startTime), TIMEZONE, 'iso'), + endTime: formatInTimezone(new Date(t.endTime), TIMEZONE, 'iso'), + })), ); const ld: LiveData = { @@ -652,17 +685,22 @@ export class OceanParkHongKong extends Destination { }); } - // The Summit zone closes earlier than the main park on some days + // The Summit zone closes earlier than the main park on some days. + // API has `summitStaus` (sic — upstream typo); also accept `summitStatus` + // defensively in case it's ever corrected. There's no `summitOpenTime` + // in the payload, so we assume Summit opens with the main park — + // documented as part of the description so downstream consumers know. + const summitStatus = day.summitStaus ?? (day as {summitStatus?: string}).summitStatus; const summitClose = parseTs(day.summitCloseTime); if ( - day.summitStaus === 'open' && + summitStatus === 'open' && summitClose && summitClose.getTime() < close.getTime() ) { scheduleEntries.push({ date: day.openDate, type: 'INFORMATIONAL', - description: 'The Summit', + description: 'The Summit (closes earlier than the rest of the park)', openingTime: formatInTimezone(open, TIMEZONE, 'iso'), closingTime: formatInTimezone(summitClose, TIMEZONE, 'iso'), }); From d3fc95cd8bbf547305ed64394ea72a550baa4d27 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 31 May 2026 20:17:38 +0000 Subject: [PATCH 5/6] fix(oceanpark): null-safe wait-time + drop accidental README rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Copilot's review of the 10-fix commit: 1. wait time: Number(null) is 0, so the previous coercion was silently emitting a 0-minute "wait" for open attractions whose API response left entityWaitTime null/undefined. Reject null/undefined/empty BEFORE coercing so only real numeric waits flow through. 2. README: the rebase resolution earlier in this PR kept the contributor's old README rewrite on top of main's newer rewrite (took --theirs instead of --ours during conflict resolution). That pulled in stale paths, an out-of-date destination list, spelling errors, and an env-var section missing Ocean Park's own keys — all five of Copilot's README findings. README is out of scope for adding a park; restoring main's version drops all five at once. TODO.MD's "Ocean Park" addition stays — that's a legitimate cross-cut that goes with the park add. Co-Authored-By: Claude Opus 4.7 --- README.md | 723 +++++++++---------------------- src/parks/oceanpark/oceanpark.ts | 5 +- 2 files changed, 201 insertions(+), 527 deletions(-) diff --git a/README.md b/README.md index 6d000fd3c..532cf4541 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# ThemeParks.wiki Parks API +# ThemeParks.wiki Park Data Backend -[![Unit Test](https://github.com/ThemeParks/parksapi/actions/workflows/unit_test.js.yml/badge.svg)](https://github.com/ThemeParks/parksapi/actions/workflows/unit_test.js.yml) [![pages-build-deployment](https://github.com/ThemeParks/parksapi/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/ThemeParks/parksapi/actions/workflows/pages/pages-build-deployment) +An open-source TypeScript library for fetching real-time theme park data — wait times, schedules, and entity metadata — from 75+ destinations worldwide. + +This library powers the free API at [ThemeParks.wiki](https://themeparks.wiki). + +**License:** MIT ## Sponsored By @@ -9,547 +13,214 @@ TouringPlans.com - - TouringPlans.com - + TouringPlans.com -## ThemeParks.wiki API - -[API Documentation](https://themeparks.github.io/parksapi/) - -This is a backend module to fetch and query live data for themeparks. This source code powers the free-to-use API at [ThemeParks.wiki](https://themeparks.wiki) - -To fetch data from the API, you should look at the client libraries: -* https://github.com/ThemeParks/ThemeParks_JavaScript -* https://github.com/ThemeParks/ThemeParks_Python - -Fetching and parsing logic is provided in this source code. Most parks require some form of credentials, which are not supplied in this repo. You will need to source these yourself. - -General support is available for the ThemeParks.wiki API, and not this source code (except for Sponsors with support benefits). - -`test.js` contains a basic set of sanity checks and output validation, which also shows how a destination object can be accessed. - -Each destination generates *entities*. Entities can be of various types: - -* Destinations - * i.e, Resorts - * These are not called resorts to avoid confusion for parks that are not part of resorts - * eg. Walt Disney World Resort -* Parks - * All parks must be within a Destination entity - * eg. Magic Kingdom -* Attraction - * A ride / transport / etc. - * Attraction entities must be within a Destination entity (usually also within a park, but not always) - * eg. Pirates of the Carribean -* Resturant - * A dining location within a destination - * eg. Casey's Corner -* Show - * A show / parade entity with scheduled show times - * eg. Main Street Electrical Parade - -*Entity Types Being Finalised - Destinations, Parks, and Attractions however, are well supported and unlikely to change* - -## Adding Destinations - -Best current documentation for this is to check out `scripts/templateDestination.js` and look at the existing supported destinations. - -Setup `index.js` with your new class, and edit `test.js` to test your destination to check everything is setup correctly. - -## Destinations - - -* WaltDisneyWorldResort -* DisneylandResort -* DisneylandParis -* TokyoDisneyResort -* HongKongDisneyland -* ShanghaiDisneylandResort -* UniversalStudios -* UniversalOrlando -* EuropaPark -* Efteling -* Phantasialand -* SeaworldOrlando -* SeaworldSanAntonio -* SeaworldSanDiego -* BuschGardensTampa -* BuschGardensWilliamsburg -* AltonTowers -* ThorpePark -* ChessingtonWorldOfAdventures -* LegolandWindsor -* LegolandOrlando -* LegolandCalifornia -* LegolandBillund -* LegolandDeutschland -* Gardaland -* PortAventuraWorld -* ParcAsterix -* Toverland -* Dollywood -* SilverDollarCity -* Plopsaland -* HolidayPark -* Bellewaerde -* WalibiHolland -* WalibiBelgium -* HeidePark -* Liseberg -* CedarPoint -* KnottsBerryFarm -* CaliforniasGreatAmerica -* CanadasWonderland -* Carowinds -* KingsIsland -* DorneyPark -* KingsDominion -* MichigansAdventure -* ValleyFair -* WorldsOfFun -* Hersheypark -* SixFlags -* HansaPark -* Knoebels -* OceanParkHongKong - + -## Configuration +## Quick Start + +**Requirements:** Node.js 24+, npm 11+ + +```bash +git clone https://github.com/ThemeParks/parksapi.git +cd parksapi +npm install +touch .env # Add your API credentials +npm run dev # Test all parks +``` + +Most parks require API credentials not provided in this repo — you must source these yourself. + +## Usage + +```typescript +import {getDestinationById} from '@themeparks/parksapi'; + +const dest = await getDestinationById('universalorlando'); +const park = new dest.DestinationClass(); + +const entities = await park.getEntities(); // Rides, shows, restaurants +const liveData = await park.getLiveData(); // Wait times, statuses +const schedules = await park.getSchedules(); // Operating hours +``` + +## Client Libraries -Destination objects can be configured by either passing an object of variables into the constructor, or using environment variables (the preferred way). +To fetch data from the ThemeParks.wiki API (rather than running this library directly): -To use environment variables, create a file called `.env` in the working directory of your project. +- [JavaScript Client](https://github.com/ThemeParks/ThemeParks_JavaScript) +- [Python Client](https://github.com/ThemeParks/ThemeParks_Python) -Environment variables are structured as: -* Class name (upper case) -* Underscore _ -* Variable name (upper case) +## Commands -For example, the class `WaltDisneyWorldResort` has a `resortId` configuration variable which can be loaded through the environment variable `WALTDISNEYWORLDRESORT_RESORTID` +```bash +npm run build # Compile TypeScript +npm run dev # Test all parks +npm run dev -- # Test specific park (e.g. universalorlando) +npm run dev -- --list # List all available park IDs +npm test # Run unit tests +npm run test:coverage # Coverage report +npm run health # Health check all endpoints +``` + +## Supported Destinations + +75 destinations across Disney, Universal, Cedar Fair, Six Flags, Merlin, and many more. + +Run `npm run dev -- --list` for the full list with IDs and categories, or see below: + +
+All destinations + +| Destination | ID | +|---|---| +| Alton Towers | `altontowers` | +| Bellewaerde | `bellewaerde` | +| Bobbejaanland | `bobbejaanland` | +| Busch Gardens Tampa | `buschgardenstampa` | +| Busch Gardens Williamsburg | `buschgardenswilliamsburg` | +| California's Great America | `californiasgreatamerica` | +| Canada's Wonderland | `canadaswonderland` | +| Carowinds | `carowinds` | +| Cedar Point | `cedarpoint` | +| Chessington World of Adventures | `chessingtonworldofadventures` | +| Chimelong | `chimelong` | +| Disneyland Paris | `disneylandparis` | +| Djurs Sommerland | `djurssommerland` | +| Dollywood | `dollywood` | +| Dorney Park | `dorneypark` | +| Efteling | `efteling` | +| Europa-Park | `europapark` | +| Everland | `everland` | +| Futuroscope | `futuroscope` | +| Gardaland | `gardaland` | +| Hansa-Park | `hansapark` | +| Heide Park | `heidepark` | +| Hersheypark | `hersheypark` | +| Kennywood | `kennywood` | +| Kings Dominion | `kingsdominion` | +| Kings Island | `kingsisland` | +| Knoebels | `knoebels` | +| Knott's Berry Farm | `knottsberryfarm` | +| Legoland Billund | `legolandbillund` | +| Legoland California | `legolandcalifornia` | +| Legoland Deutschland | `legolanddeutschland` | +| Legoland Japan | `legolandjapan` | +| Legoland Korea | `legolandkorea` | +| Legoland New York | `legolandnewyork` | +| Legoland Orlando | `legolandorlando` | +| Legoland Windsor | `legolandwindsor` | +| Liseberg | `liseberg` | +| Lotte World | `lotteworld` | +| Michigan's Adventure | `michigansadventure` | +| Mirabilandia | `mirabilandia` | +| Movie Park Germany | `movieparkgermany` | +| Parc Asterix | `parcasterix` | +| Paradise Country | `paradisecountry` | +| Parque de Atracciones Madrid | `parquedeatraccionesmadrid` | +| Parque Warner Madrid | `parquewarnermadrid` | +| Paultons Park | `paultonspark` | +| Peppa Pig Theme Park Florida | `peppapigthemeparkflorida` | +| Phantasialand | `phantasialand` | +| Plopsaland | `plopsaland` | +| Plopsaland Deutschland | `plopsalanddeutschland` | +| PortAventura World | `portaventuraworld` | +| Sea World Gold Coast | `seaworldgoldcoast` | +| SeaWorld Orlando | `seaworldorlando` | +| SeaWorld San Antonio | `seaworldsanantonio` | +| SeaWorld San Diego | `seaworldsandiego` | +| Shanghai Disneyland Resort | `shanghaidisneylandresort` | +| Silver Dollar City | `silverdollarcity` | +| Six Flags | `sixflags` | +| Six Flags Qiddiya City | `sixflagsqiddiyacity` | +| Thorpe Park | `thorpepark` | +| Tokyo Disney Resort | `tokyodisneyresort` | +| Toverland | `toverland` | +| Universal Orlando | `universalorlando` | +| Universal Singapore | `universalsingapore` | +| Universal Studios | `universalstudios` | +| Universal Studios Beijing | `universalstudiosbeijing` | +| Universal Studios Japan | `universalstudiosjapan` | +| Valleyfair | `valleyfair` | +| Walibi Belgium | `walibibelgium` | +| Walibi Holland | `walibiholland` | +| Walibi Rhone-Alpes | `walibirhonealpes` | +| Warner Bros. Movie World | `warnerbrosmovieworld` | +| Wet'n'Wild Gold Coast | `wetnwildgoldcoast` | +| Worlds of Fun | `worldsoffun` | + +
+ +## Entity Types + +Each destination produces **entities** of the following types: + +- **Destination** — A resort or group of parks (e.g., Walt Disney World Resort) +- **Park** — A theme park within a destination (e.g., Magic Kingdom) +- **Attraction** — A ride, transport, or similar experience (e.g., Pirates of the Caribbean) +- **Show** — A performance or parade with scheduled show times +- **Restaurant** — A dining location -Some classes expose "parent scopes" to allow configuring environment variables once for a range of destinations. For example, Attractions.io's base URL can be configured once through `ATTRACTIONSIO_BASEURL` rather than configuring it identically for every destination. +## Configuration -Environment Variables (auto-generated): +Environment variables follow the pattern `{CLASSNAME}_{PROPERTY}`: - ``` -WALTDISNEYWORLDRESORT_RESORTID -WALTDISNEYWORLDRESORT_RESORTSHORTCODE -WALTDISNEYWORLDRESORT_PARKIDS -WALTDISNEYWORLDRESORT_VIRTUALQUEUEURL -WALTDISNEYWORLDRESORT_GENIEDATA -WALTDISNEYWORLDRESORT_SLUG -DISNEYLANDRESORT_RESORTID -DISNEYLANDRESORT_RESORTSHORTCODE -DISNEYLANDRESORT_PARKIDS -DISNEYLANDRESORT_VIRTUALQUEUEURL -DISNEYLANDRESORT_GENIEDATA -DISNEYLANDRESORT_SLUG -DISNEYLANDPARIS_APIKEY -DISNEYLANDPARIS_APIBASE -DISNEYLANDPARIS_APIBASEWAITTIMES -DISNEYLANDPARIS_LANGUAGE -DISNEYLANDPARIS_STANDBYAPIBASE -DISNEYLANDPARIS_STANDBYAPIKEY -DISNEYLANDPARIS_STANDBYAUTHURL -DISNEYLANDPARIS_STANDBYAPIREFRESHTOKEN -DISNEYLANDPARIS_PREMIERACCESSAPIKEY -DISNEYLANDPARIS_PREMIERACCESSURL -DISNEYLANDPARIS_USERAGENT -TOKYODISNEYRESORT_APIKEY -TOKYODISNEYRESORT_APIAUTH -TOKYODISNEYRESORT_APIOS -TOKYODISNEYRESORT_APIBASE -TOKYODISNEYRESORT_APIVERSION -TOKYODISNEYRESORT_PARKIDS -TOKYODISNEYRESORT_FALLBACKDEVICEID -HONGKONGDISNEYLAND_RESORTID -HONGKONGDISNEYLAND_DESTINATIONID -HONGKONGDISNEYLAND_RESORTSHORTCODE -HONGKONGDISNEYLAND_CULTUREFILTER -HONGKONGDISNEYLAND_PARKIDS -HONGKONGDISNEYLAND_SLUG -HONGKONGDISNEYLAND_VIRTUALQUEUEURL -HONGKONGDISNEYLAND_GENIEDATA -SHANGHAIDISNEYLANDRESORT_APIBASE -SHANGHAIDISNEYLANDRESORT_APIAUTH -SHANGHAIDISNEYLANDRESORT_PARKIDS -UNIVERSALSTUDIOS_CITY -UNIVERSALSTUDIOS_RESORTSLUG -UNIVERSALSTUDIOS_RESORTKEY -UNIVERSALSTUDIOS_SECRETKEY -UNIVERSALSTUDIOS_APPKEY -UNIVERSALSTUDIOS_VQUEUEURL -UNIVERSALSTUDIOS_BASEURL -UNIVERSALSTUDIOS_ASSETSBASE -UNIVERSALORLANDO_CITY -UNIVERSALORLANDO_RESORTSLUG -UNIVERSALORLANDO_RESORTKEY -UNIVERSALORLANDO_SECRETKEY -UNIVERSALORLANDO_APPKEY -UNIVERSALORLANDO_VQUEUEURL -UNIVERSALORLANDO_BASEURL -UNIVERSALORLANDO_ASSETSBASE -EUROPAPARK_PARKS -EFTELING_APIKEY -EFTELING_APIVERSION -EFTELING_APPVERSION -EFTELING_SEARCHURL -EFTELING_WAITTIMESURL -PHANTASIALAND_APIBASE -SEAWORLDORLANDO_RESORTIDS -SEAWORLDORLANDO_RESORTID -SEAWORLDORLANDO_RESORTSLUG -SEAWORLDORLANDO_APPID -SEAWORLDORLANDO_APPVERSION -SEAWORLDORLANDO_BASEURL -SEAWORLDSANANTONIO_RESORTIDS -SEAWORLDSANANTONIO_RESORTID -SEAWORLDSANANTONIO_RESORTSLUG -SEAWORLDSANANTONIO_APPID -SEAWORLDSANANTONIO_APPVERSION -SEAWORLDSANANTONIO_BASEURL -SEAWORLDSANDIEGO_RESORTIDS -SEAWORLDSANDIEGO_RESORTID -SEAWORLDSANDIEGO_RESORTSLUG -SEAWORLDSANDIEGO_APPID -SEAWORLDSANDIEGO_APPVERSION -SEAWORLDSANDIEGO_BASEURL -BUSCHGARDENSTAMPA_RESORTIDS -BUSCHGARDENSTAMPA_RESORTID -BUSCHGARDENSTAMPA_RESORTSLUG -BUSCHGARDENSTAMPA_APPID -BUSCHGARDENSTAMPA_APPVERSION -BUSCHGARDENSTAMPA_BASEURL -BUSCHGARDENSWILLIAMSBURG_RESORTIDS -BUSCHGARDENSWILLIAMSBURG_RESORTID -BUSCHGARDENSWILLIAMSBURG_RESORTSLUG -BUSCHGARDENSWILLIAMSBURG_APPID -BUSCHGARDENSWILLIAMSBURG_APPVERSION -BUSCHGARDENSWILLIAMSBURG_BASEURL -ALTONTOWERS_DESTINATIONID -ALTONTOWERS_PARKID -ALTONTOWERS_INITIALDATAVERSION -ALTONTOWERS_APPBUILD -ALTONTOWERS_APPVERSION -ALTONTOWERS_BASEURL -ALTONTOWERS_DEVICEIDENTIFIER -ALTONTOWERS_APIKEY -ALTONTOWERS_CALENDARURL -THORPEPARK_DESTINATIONID -THORPEPARK_PARKID -THORPEPARK_INITIALDATAVERSION -THORPEPARK_APPBUILD -THORPEPARK_APPVERSION -THORPEPARK_BASEURL -THORPEPARK_DEVICEIDENTIFIER -THORPEPARK_APIKEY -THORPEPARK_CALENDARURL -CHESSINGTONWORLDOFADVENTURES_DESTINATIONID -CHESSINGTONWORLDOFADVENTURES_PARKID -CHESSINGTONWORLDOFADVENTURES_INITIALDATAVERSION -CHESSINGTONWORLDOFADVENTURES_APPBUILD -CHESSINGTONWORLDOFADVENTURES_APPVERSION -CHESSINGTONWORLDOFADVENTURES_BASEURL -CHESSINGTONWORLDOFADVENTURES_DEVICEIDENTIFIER -CHESSINGTONWORLDOFADVENTURES_APIKEY -CHESSINGTONWORLDOFADVENTURES_CALENDARURL -LEGOLANDWINDSOR_DESTINATIONID -LEGOLANDWINDSOR_PARKID -LEGOLANDWINDSOR_INITIALDATAVERSION -LEGOLANDWINDSOR_APPBUILD -LEGOLANDWINDSOR_APPVERSION -LEGOLANDWINDSOR_BASEURL -LEGOLANDWINDSOR_DEVICEIDENTIFIER -LEGOLANDWINDSOR_APIKEY -LEGOLANDWINDSOR_CALENDARURL -LEGOLANDORLANDO_DESTINATIONID -LEGOLANDORLANDO_PARKID -LEGOLANDORLANDO_INITIALDATAVERSION -LEGOLANDORLANDO_APPBUILD -LEGOLANDORLANDO_APPVERSION -LEGOLANDORLANDO_BASEURL -LEGOLANDORLANDO_DEVICEIDENTIFIER -LEGOLANDORLANDO_APIKEY -LEGOLANDORLANDO_CALENDARURL -LEGOLANDCALIFORNIA_DESTINATIONID -LEGOLANDCALIFORNIA_PARKID -LEGOLANDCALIFORNIA_INITIALDATAVERSION -LEGOLANDCALIFORNIA_APPBUILD -LEGOLANDCALIFORNIA_APPVERSION -LEGOLANDCALIFORNIA_BASEURL -LEGOLANDCALIFORNIA_DEVICEIDENTIFIER -LEGOLANDCALIFORNIA_APIKEY -LEGOLANDCALIFORNIA_CALENDARURL -LEGOLANDBILLUND_DESTINATIONID -LEGOLANDBILLUND_PARKID -LEGOLANDBILLUND_INITIALDATAVERSION -LEGOLANDBILLUND_APPBUILD -LEGOLANDBILLUND_APPVERSION -LEGOLANDBILLUND_BASEURL -LEGOLANDBILLUND_DEVICEIDENTIFIER -LEGOLANDBILLUND_APIKEY -LEGOLANDBILLUND_CALENDARURL -LEGOLANDDEUTSCHLAND_DESTINATIONID -LEGOLANDDEUTSCHLAND_PARKID -LEGOLANDDEUTSCHLAND_INITIALDATAVERSION -LEGOLANDDEUTSCHLAND_APPBUILD -LEGOLANDDEUTSCHLAND_APPVERSION -LEGOLANDDEUTSCHLAND_BASEURL -LEGOLANDDEUTSCHLAND_DEVICEIDENTIFIER -LEGOLANDDEUTSCHLAND_APIKEY -LEGOLANDDEUTSCHLAND_CALENDARURL -GARDALAND_DESTINATIONID -GARDALAND_PARKID -GARDALAND_INITIALDATAVERSION -GARDALAND_APPBUILD -GARDALAND_APPVERSION -GARDALAND_BASEURL -GARDALAND_DEVICEIDENTIFIER -GARDALAND_APIKEY -GARDALAND_CALENDARURL -PORTAVENTURAWORLD_APIBASE -PORTAVENTURAWORLD_GUESTUSERNAME -PORTAVENTURAWORLD_GUESTPASSWORD -PORTAVENTURAWORLD_WAITTIMEURL -PARCASTERIX_APIBASE -PARCASTERIX_LANGUAGE -TOVERLAND_APIBASE -TOVERLAND_CALENDARURL -TOVERLAND_AUTHTOKEN -TOVERLAND_LANGUAGES -DOLLYWOOD_DESTINATIONID -DOLLYWOOD_DESTINATIONSLUG -DOLLYWOOD_CRMBASEURL -DOLLYWOOD_CRMGUID -DOLLYWOOD_CRMATTRACTIONSID -DOLLYWOOD_CRMDININGID -DOLLYWOOD_CRMSHOWID -DOLLYWOOD_APIBASE -DOLLYWOOD_CRMAUTH -SILVERDOLLARCITY_DESTINATIONID -SILVERDOLLARCITY_DESTINATIONSLUG -SILVERDOLLARCITY_CRMBASEURL -SILVERDOLLARCITY_CRMGUID -SILVERDOLLARCITY_CRMATTRACTIONSID -SILVERDOLLARCITY_CRMDININGID -SILVERDOLLARCITY_CRMSHOWID -SILVERDOLLARCITY_APIBASE -SILVERDOLLARCITY_CRMAUTH -PLOPSALAND_DESTINATIONSLUG -PLOPSALAND_PARKSLUG -PLOPSALAND_BASEURL -PLOPSALAND_BASELANG -PLOPSALAND_CLIENTID -PLOPSALAND_CLIENTSECRET -HOLIDAYPARK_DESTINATIONSLUG -HOLIDAYPARK_PARKSLUG -HOLIDAYPARK_BASEURL -HOLIDAYPARK_BASELANG -HOLIDAYPARK_CLIENTID -HOLIDAYPARK_CLIENTSECRET -BELLEWAERDE_BASEURL -BELLEWAERDE_DESTINATIONSLUG -BELLEWAERDE_PARKSLUG -BELLEWAERDE_APISHORTCODE -BELLEWAERDE_CULTURE -BELLEWAERDE_APIKEY -WALIBIHOLLAND_APIKEY -WALIBIHOLLAND_BASEURL -WALIBIHOLLAND_DESTINATIONSLUG -WALIBIHOLLAND_PARKSLUG -WALIBIHOLLAND_APISHORTCODE -WALIBIHOLLAND_CULTURE -HEIDEPARK_DESTINATIONID -HEIDEPARK_PARKID -HEIDEPARK_INITIALDATAVERSION -HEIDEPARK_APPBUILD -HEIDEPARK_APPVERSION -HEIDEPARK_BASEURL -HEIDEPARK_DEVICEIDENTIFIER -HEIDEPARK_APIKEY -HEIDEPARK_CALENDARURL -LISEBERG_RESORTID -LISEBERG_BASEURL -CEDARPOINT_PARKID -CEDARPOINT_DESTINATIONID -CEDARPOINT_BASEURL -CEDARPOINT_REALTIMEBASEURL -CEDARPOINT_CONFIGPATH -CEDARPOINT_LONGITUDE -CEDARPOINT_LATITUDE -CEDARPOINT_EXTRAATTRACTIONCATEGORYTYPES -CEDARPOINT_EXTRASHOWCATEGORYTYPES -CEDARPOINT_EXTRARESTAURANTCATEGORYTYPES -CEDARPOINT_ATTRACTIONCATEGORIES -CEDARPOINT_SHOWCATEGORIES -CEDARPOINT_DININGCATEGORIES -KNOTTSBERRYFARM_PARKID -KNOTTSBERRYFARM_DESTINATIONID -KNOTTSBERRYFARM_EXTRAATTRACTIONCATEGORYTYPES -KNOTTSBERRYFARM_BASEURL -KNOTTSBERRYFARM_REALTIMEBASEURL -KNOTTSBERRYFARM_CONFIGPATH -KNOTTSBERRYFARM_LONGITUDE -KNOTTSBERRYFARM_LATITUDE -KNOTTSBERRYFARM_EXTRASHOWCATEGORYTYPES -KNOTTSBERRYFARM_EXTRARESTAURANTCATEGORYTYPES -KNOTTSBERRYFARM_ATTRACTIONCATEGORIES -KNOTTSBERRYFARM_SHOWCATEGORIES -KNOTTSBERRYFARM_DININGCATEGORIES -CALIFORNIASGREATAMERICA_PARKID -CALIFORNIASGREATAMERICA_DESTINATIONID -CALIFORNIASGREATAMERICA_BASEURL -CALIFORNIASGREATAMERICA_REALTIMEBASEURL -CALIFORNIASGREATAMERICA_CONFIGPATH -CALIFORNIASGREATAMERICA_LONGITUDE -CALIFORNIASGREATAMERICA_LATITUDE -CALIFORNIASGREATAMERICA_EXTRAATTRACTIONCATEGORYTYPES -CALIFORNIASGREATAMERICA_EXTRASHOWCATEGORYTYPES -CALIFORNIASGREATAMERICA_EXTRARESTAURANTCATEGORYTYPES -CALIFORNIASGREATAMERICA_ATTRACTIONCATEGORIES -CALIFORNIASGREATAMERICA_SHOWCATEGORIES -CALIFORNIASGREATAMERICA_DININGCATEGORIES -CANADASWONDERLAND_PARKID -CANADASWONDERLAND_DESTINATIONID -CANADASWONDERLAND_BASEURL -CANADASWONDERLAND_REALTIMEBASEURL -CANADASWONDERLAND_CONFIGPATH -CANADASWONDERLAND_LONGITUDE -CANADASWONDERLAND_LATITUDE -CANADASWONDERLAND_EXTRAATTRACTIONCATEGORYTYPES -CANADASWONDERLAND_EXTRASHOWCATEGORYTYPES -CANADASWONDERLAND_EXTRARESTAURANTCATEGORYTYPES -CANADASWONDERLAND_ATTRACTIONCATEGORIES -CANADASWONDERLAND_SHOWCATEGORIES -CANADASWONDERLAND_DININGCATEGORIES -CAROWINDS_PARKID -CAROWINDS_DESTINATIONID -CAROWINDS_BASEURL -CAROWINDS_REALTIMEBASEURL -CAROWINDS_CONFIGPATH -CAROWINDS_LONGITUDE -CAROWINDS_LATITUDE -CAROWINDS_EXTRAATTRACTIONCATEGORYTYPES -CAROWINDS_EXTRASHOWCATEGORYTYPES -CAROWINDS_EXTRARESTAURANTCATEGORYTYPES -CAROWINDS_ATTRACTIONCATEGORIES -CAROWINDS_SHOWCATEGORIES -CAROWINDS_DININGCATEGORIES -KINGSISLAND_PARKID -KINGSISLAND_DESTINATIONID -KINGSISLAND_BASEURL -KINGSISLAND_REALTIMEBASEURL -KINGSISLAND_CONFIGPATH -KINGSISLAND_LONGITUDE -KINGSISLAND_LATITUDE -KINGSISLAND_EXTRAATTRACTIONCATEGORYTYPES -KINGSISLAND_EXTRASHOWCATEGORYTYPES -KINGSISLAND_EXTRARESTAURANTCATEGORYTYPES -KINGSISLAND_ATTRACTIONCATEGORIES -KINGSISLAND_SHOWCATEGORIES -KINGSISLAND_DININGCATEGORIES -DORNEYPARK_PARKID -DORNEYPARK_DESTINATIONID -DORNEYPARK_CONFIGPATH -DORNEYPARK_BASEURL -DORNEYPARK_REALTIMEBASEURL -DORNEYPARK_LONGITUDE -DORNEYPARK_LATITUDE -DORNEYPARK_EXTRAATTRACTIONCATEGORYTYPES -DORNEYPARK_EXTRASHOWCATEGORYTYPES -DORNEYPARK_EXTRARESTAURANTCATEGORYTYPES -DORNEYPARK_ATTRACTIONCATEGORIES -DORNEYPARK_SHOWCATEGORIES -DORNEYPARK_DININGCATEGORIES -KINGSDOMINION_PARKID -KINGSDOMINION_DESTINATIONID -KINGSDOMINION_CONFIGPATH -KINGSDOMINION_BASEURL -KINGSDOMINION_REALTIMEBASEURL -KINGSDOMINION_LONGITUDE -KINGSDOMINION_LATITUDE -KINGSDOMINION_EXTRAATTRACTIONCATEGORYTYPES -KINGSDOMINION_EXTRASHOWCATEGORYTYPES -KINGSDOMINION_EXTRARESTAURANTCATEGORYTYPES -KINGSDOMINION_ATTRACTIONCATEGORIES -KINGSDOMINION_SHOWCATEGORIES -KINGSDOMINION_DININGCATEGORIES -MICHIGANSADVENTURE_PARKID -MICHIGANSADVENTURE_DESTINATIONID -MICHIGANSADVENTURE_CONFIGPATH -MICHIGANSADVENTURE_BASEURL -MICHIGANSADVENTURE_REALTIMEBASEURL -MICHIGANSADVENTURE_LONGITUDE -MICHIGANSADVENTURE_LATITUDE -MICHIGANSADVENTURE_EXTRAATTRACTIONCATEGORYTYPES -MICHIGANSADVENTURE_EXTRASHOWCATEGORYTYPES -MICHIGANSADVENTURE_EXTRARESTAURANTCATEGORYTYPES -MICHIGANSADVENTURE_ATTRACTIONCATEGORIES -MICHIGANSADVENTURE_SHOWCATEGORIES -MICHIGANSADVENTURE_DININGCATEGORIES -VALLEYFAIR_PARKID -VALLEYFAIR_DESTINATIONID -VALLEYFAIR_CONFIGPATH -VALLEYFAIR_BASEURL -VALLEYFAIR_REALTIMEBASEURL -VALLEYFAIR_LONGITUDE -VALLEYFAIR_LATITUDE -VALLEYFAIR_EXTRAATTRACTIONCATEGORYTYPES -VALLEYFAIR_EXTRASHOWCATEGORYTYPES -VALLEYFAIR_EXTRARESTAURANTCATEGORYTYPES -VALLEYFAIR_ATTRACTIONCATEGORIES -VALLEYFAIR_SHOWCATEGORIES -VALLEYFAIR_DININGCATEGORIES -WORLDSOFFUN_PARKID -WORLDSOFFUN_DESTINATIONID -WORLDSOFFUN_CONFIGPATH -WORLDSOFFUN_BASEURL -WORLDSOFFUN_REALTIMEBASEURL -WORLDSOFFUN_LONGITUDE -WORLDSOFFUN_LATITUDE -WORLDSOFFUN_EXTRAATTRACTIONCATEGORYTYPES -WORLDSOFFUN_EXTRASHOWCATEGORYTYPES -WORLDSOFFUN_EXTRARESTAURANTCATEGORYTYPES -WORLDSOFFUN_ATTRACTIONCATEGORIES -WORLDSOFFUN_SHOWCATEGORIES -WORLDSOFFUN_DININGCATEGORIES -HERSHEYPARK_APIKEY -HERSHEYPARK_BASEURL -SIXFLAGS_BASEURL -SIXFLAGS_AUTHHEADER -HANSAPARK_RESORTID -HANSAPARK_BASEURL -HANSAPARK_LOCALE -HANSAPARK_APIKEY -KNOEBELS_DESTINATIONID -KNOEBELS_PARKID -KNOEBELS_INITIALDATAVERSION -KNOEBELS_APPBUILD -KNOEBELS_APPVERSION -KNOEBELS_BASEURL -KNOEBELS_DEVICEIDENTIFIER -KNOEBELS_APIKEY -KNOEBELS_CALENDARURL -WALIBIBELGIUM_APIKEY -WALIBIBELGIUM_BASEURL -WALIBIBELGIUM_DESTINATIONSLUG -WALIBIBELGIUM_PARKSLUG -WALIBIBELGIUM_APISHORTCODE -WALIBIBELGIUM_CULTURE +UNIVERSALORLANDO_APIKEY=your-key-here +EFTELING_APPVERSION=5.0.0 ``` - + +Create a `.env` file in the project root. Some destinations share configuration via prefixes (e.g., `ATTRACTIONSIO_BASEURL` applies to all Attractions.io parks). + +Run `npm run dev -- -v` to see which config properties a destination expects. + +## Architecture + +The library uses a **decorator-based design** with TypeScript: + +- **`@destinationController`** — Auto-registers destinations, applies config proxy +- **`@config`** — Property-level config injection from env vars +- **`@http`** — Queue-based HTTP with retry, caching, validation +- **`@inject`** — Event-based dependency injection (auth headers, response transforms) +- **`@cache`** — SQLite-backed caching with TTL + +All parks extend the `Destination` base class using the **Template Method Pattern** — implement `buildEntityList()`, `buildLiveData()`, and `buildSchedules()`. + +See `CLAUDE.md` for full architecture documentation. + +## Contributing + +Contributions are welcome. To add a new destination: + +1. Create `src/parks//.ts` extending `Destination` +2. Implement entity, live data, and schedule methods +3. Test with `npm run dev -- ` +4. Submit a PR + +See `CLAUDE.md` and `.claude/skills/implementing-parks.md` for detailed implementation guidance. + +## Support + +General support is available for the [ThemeParks.wiki API](https://themeparks.wiki). This source code is self-service (sponsors get support benefits). + +## API Documentation + +[https://themeparks.github.io/parksapi/](https://themeparks.github.io/parksapi/) diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts index 0d62b49d6..3f8cb6df1 100644 --- a/src/parks/oceanpark/oceanpark.ts +++ b/src/parks/oceanpark/oceanpark.ts @@ -586,7 +586,10 @@ export class OceanParkHongKong extends Destination { // Coerce + finite-check before emitting. The interface declares // `number | null` but upstream APIs sometimes send strings; CLAUDE.md // requires Number.isFinite over isNaN to handle empty-string coercion. - const wt = Number(pflow.entityWaitTime); + // Reject null/undefined/empty BEFORE coercing — Number(null) is 0, + // which would silently emit "0 min wait" for unknown waits. + const raw = pflow.entityWaitTime as number | string | null | undefined; + const wt = raw == null || raw === '' ? NaN : Number(raw); if (isOpen && Number.isFinite(wt) && wt >= 0) { ld.queue = {STANDBY: {waitTime: wt}}; } From 81c392138484109e9184b078e918a06c8a45c3ed Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 31 May 2026 20:26:42 +0000 Subject: [PATCH 6/6] fix(oceanpark): identify transport entities by source list, not typeId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Copilot review on the previous fix commit: `isTransport` was checking `entity.typeId === SORT_ID.TRANSPORT`, but `typeId` is optional on the API and uses a different ID space than the SORT_ID constants — so every transport entity was silently being emitted with attractionType RIDE. Use the slice position instead: the concatenation is `[...rides, ...transport]`, so everything at index >= rides.length came from the transport list. Verified locally: now 4 entities are TRANSPORT (Ocean Express, etc.) and the remaining 46 stay RIDE. Co-Authored-By: Claude Opus 4.7 --- src/parks/oceanpark/oceanpark.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts index 3f8cb6df1..8753cc50f 100644 --- a/src/parks/oceanpark/oceanpark.ts +++ b/src/parks/oceanpark/oceanpark.ts @@ -491,7 +491,11 @@ export class OceanParkHongKong extends Destination { ); const attractionEntities: Entity[] = attractions.map((entity, i) => { - const isTransport = entity.typeId === SORT_ID.TRANSPORT; + // Source of truth for transport-vs-ride is the list this entity came + // from, not `entity.typeId` (which is optional and uses a different + // ID space than SORT_ID). Everything past `rides.length` came from + // the transport list. + const isTransport = i >= rides.length; const coords = coordMap.get(String(entity.extEntityCode)); const detail = details[i]; const tags = [];