diff --git a/src/parks/universal/__tests__/places.test.ts b/src/parks/universal/__tests__/places.test.ts index 6e5a3c5b..9c9e485d 100644 --- a/src/parks/universal/__tests__/places.test.ts +++ b/src/parks/universal/__tests__/places.test.ts @@ -74,6 +74,102 @@ describe('placeToEntity', () => { expect(e?.parentId).toBe('uor.ioa'); }); + // USH Upper/Lower Lot are sub-areas of the single `ush.ush` park (not surfaced + // as parks themselves), so their children must reparent onto `ush.ush` or they + // strand in the sync's unresolved-parent queue forever. + test('USH Upper Lot child → reparented to ush.ush', () => { + const upperLotDining: UniversalPlace = { + place_id: 'ush.dining.krusty_burger', + name: 'Krusty Burger', + resort_area_code: 'ush', + venue_id: 'ush.upper_lot', + place_type: {type: 'Dining'}, + }; + expect(placeToEntity(upperLotDining, DESTINATION, TZ)?.parentId).toBe('ush.ush'); + }); + + test('USH Lower Lot child → reparented to ush.ush', () => { + const lowerLotRide: UniversalPlace = { + place_id: 'ush.lower_lot.rides.jurassic_world_the_ride', + name: 'Jurassic World — The Ride', + resort_area_code: 'ush', + venue_id: 'ush.lower_lot', + place_type: {type: 'Ride'}, + }; + expect(placeToEntity(lowerLotRide, DESTINATION, TZ)?.parentId).toBe('ush.ush'); + }); + + test('CityWalk child → null (district not surfaced as a park; excluded)', () => { + const ushCityWalk: UniversalPlace = { + place_id: 'ush.cw.dining.voodoo_doughnut', + name: 'Voodoo Doughnut', + resort_area_code: 'ush', + venue_id: 'ush.cw', + place_type: {type: 'Dining'}, + }; + const uorCityWalk: UniversalPlace = { + place_id: 'uor.cw.dining.voodoo_doughnut', + name: 'Voodoo Doughnut', + resort_area_code: 'uor', + venue_id: 'uor.cw', + place_type: {type: 'Dining'}, + }; + expect(placeToEntity(ushCityWalk, DESTINATION, TZ)).toBeNull(); + expect(placeToEntity(uorCityWalk, DESTINATION, TZ)).toBeNull(); + }); + + test('Surfaced-park venue is left unchanged (no accidental remap)', () => { + // ridePlace.venue_id === 'uor.usf' (a real park) must pass through as-is. + expect(placeToEntity(ridePlace, DESTINATION, TZ)?.parentId).toBe('uor.usf'); + }); + + // Event-flagged variants that alias ANOTHER canonical POI (language / last-tram + // duplicates) are dropped; genuine event entities that link to themselves stay. + const evented = ( + placeId: string, + name: string, + type: 'Ride' | 'Show', + shareTargetId: string, + isEvent = 'true', + ): UniversalPlace => ({ + place_id: placeId, + name, + resort_area_code: 'ush', + venue_id: 'ush.upper_lot', + place_type: { + type, + attributes: [ + {name: 'is_event', value: isEvent}, + { + name: 'social_sharing_link', + value: `https://www.universalstudioshollywood.com/applinks/poi?id=${shareTargetId}`, + }, + ], + }, + }); + + test('Studio Tour - Mandarin (event, shares WaterWorld id) → null', () => { + const p = evented('ush.upper_lot.shows.studio_tour_mandarin', 'Studio Tour - Mandarin', 'Show', 'ush.upper_lot.shows.waterworld'); + expect(placeToEntity(p, DESTINATION, TZ)).toBeNull(); + }); + + test('Studio Tour Last Tram (event, shares canonical Studio Tour id) → null', () => { + const p = evented('ush.upper_lot.rides.studio_tour_last_tram', 'Studio Tour Last Tram', 'Ride', 'ush.upper_lot.rides.studio_tour'); + expect(placeToEntity(p, DESTINATION, TZ)).toBeNull(); + }); + + test('Event entity whose share link points at itself → kept (Bowser Jr. Challenge)', () => { + const p = evented('ush.lower_lot.rides.bowser.jr.challenge', 'Bowser Jr. Challenge', 'Ride', 'ush.lower_lot.rides.bowser.jr.challenge'); + const e = placeToEntity(p, DESTINATION, TZ); + expect(e).not.toBeNull(); + expect(e?.parentId).toBe('ush.ush'); // and reparented off upper_lot + }); + + test('Canonical Studio Tour (not an event) → kept', () => { + const p = evented('ush.upper_lot.rides.studio_tour', 'Studio Tour', 'Ride', 'ush.upper_lot.rides.studio_tour', 'false'); + expect(placeToEntity(p, DESTINATION, TZ)).not.toBeNull(); + }); + test('Shop → null (not in PLACE_TYPE_TO_ENTITY)', () => { expect(placeToEntity(shopPlace, DESTINATION, TZ)).toBeNull(); }); diff --git a/src/parks/universal/universal.ts b/src/parks/universal/universal.ts index 67ed1ff1..9a24d8a7 100644 --- a/src/parks/universal/universal.ts +++ b/src/parks/universal/universal.ts @@ -132,11 +132,58 @@ const PARK_PLACE_ID_TO_LEGACY_VENUE_ID: Record = { 'ush.ush': '13825', }; +/** + * Park-type places we deliberately do NOT surface as parks + * (PARK_PLACE_ID_TO_LEGACY_VENUE_ID omits them) still appear as the `venue_id` + * of real child places. Left alone, those children reference a parent entity + * that is never emitted, so the sync strands them in its unresolved-parent + * queue every cycle — never pushing them (observed on USH: 73 dining / rides / + * shows held indefinitely, incl. Fast & Furious: Hollywood Drift). Reconcile + * each such venue here, keyed by its sanitized venue_id: + * - a park id → reparent the child onto that surfaced park + * - null → the venue has no wiki representation; drop the child + */ +const NON_SURFACED_VENUE_PARENT: Record = { + // USH — Upper/Lower Lot are sub-areas of the single `ush.ush` park. + 'ush.upper_lot': 'ush.ush', + 'ush.lower_lot': 'ush.ush', + // CityWalk is a dining/shopping district, not a park on the wiki — exclude. + 'ush.cw': null, + 'uor.cw': null, +}; + /** Read a single attribute value from a place's place_type.attributes[]. */ function attr(place: UniversalPlace, name: string): string | undefined { return place.place_type.attributes?.find((a) => a.name === name)?.value; } +/** + * True for event-flagged language / operational *variants* of another POI — + * e.g. USH's "Studio Tour - Mandarin/Spanish" and "Studio Tour Last Tram", + * which are alternate-language / last-departure duplicates of the canonical + * Studio Tour (their share links even point at WaterWorld). The feed marks each + * `is_event=true` AND aims its `social_sharing_link?id=` at a DIFFERENT + * canonical place_id. A genuine standalone entity's share link points at itself + * — Bowser Jr. Challenge is also `is_event=true` but links to its own id, so it + * is kept. Dropping these variants keeps the park from filling with per-language + * / operational duplicates of a ride that's already listed. + */ +function isEventVariantAlias(place: UniversalPlace): boolean { + if (attr(place, 'is_event') !== 'true') return false; + const link = attr(place, 'social_sharing_link'); + const match = typeof link === 'string' ? link.match(/[?&]id=([^&]+)/) : null; + if (!match) return false; + let sharedId: string; + try { + sharedId = decodeURIComponent(match[1]); + } catch { + sharedId = match[1]; + } + // Compare sanitized ids — the share-link id and place_id are the same raw + // scheme, but normalise defensively (matches how entity ids are formed). + return sanitizeId(sharedId) !== sanitizeId(place.place_id); +} + /** * Map a UniversalPlace to a wiki Entity. Returns null for place types we * don't expose (Park is emitted separately by buildEntityList; Shop / @@ -150,6 +197,9 @@ export function placeToEntity( const entityType = PLACE_TYPE_TO_ENTITY[place.place_type.type]; if (!entityType) return null; + // Drop per-language / operational variants of another POI (see helper). + if (isEventVariantAlias(place)) return null; + const entity: Entity = { id: sanitizeId(place.place_id), name: place.name, @@ -159,7 +209,16 @@ export function placeToEntity( } as Entity; if (place.venue_id) { - (entity as any).parentId = sanitizeId(place.venue_id); + const venue = sanitizeId(place.venue_id); + if (venue in NON_SURFACED_VENUE_PARENT) { + const reparent = NON_SURFACED_VENUE_PARENT[venue]; + // null → venue isn't represented on the wiki (e.g. CityWalk): drop the + // child rather than strand it under a parent that is never emitted. + if (reparent === null) return null; + (entity as any).parentId = reparent; + } else { + (entity as any).parentId = venue; + } } const mapLoc = place.geometry?.locations?.find((l) => l.location_type === 'map');