diff --git a/src/parks/universal/__tests__/places.test.ts b/src/parks/universal/__tests__/places.test.ts index e94d0b395..c54385858 100644 --- a/src/parks/universal/__tests__/places.test.ts +++ b/src/parks/universal/__tests__/places.test.ts @@ -454,4 +454,38 @@ describe('mapUniversalShowStatus', () => { expect(status).toBe('OPERATING'); // was 'CLOSED' — the reported contradiction expect(showtimes).toHaveLength(1); }); + + // Regression for programme#86 / parksapi USH incident: the feed lists the + // whole day's ENABLED slots from midnight, so hasFutureShowtimes stays true + // all night once the feed rolls to the next operating day. Without a clock + // gate a show sampled overnight reads OPERATING straight through the + // closure and the row never gets rewritten (frozen "current" value). + test('park closed (parkOperating=false) overrides future showtimes → CLOSED', () => { + expect(mapUniversalShowStatus('CLOSED', true, false)).toBe('CLOSED'); + expect(mapUniversalShowStatus(undefined, true, false)).toBe('CLOSED'); + }); + + test('park open (parkOperating=true) with future showtimes → OPERATING, same as the default', () => { + expect(mapUniversalShowStatus('CLOSED', true, true)).toBe('OPERATING'); + }); + + test('parkOperating defaults to true (ungated) when the caller omits it', () => { + expect(mapUniversalShowStatus('CLOSED', true)).toBe('OPERATING'); + }); + + // Live evidence (programme#86, sampled 03:24 PDT with USH's own schedule + // confirming the park shut): 25 of 31 externally-shown entries carried + // `status: "OPEN"` outright, not just a stray future showtime. The status + // field is not reliably live either, so an explicit OPEN/RIDE_NOW is + // gated the same as the showtimes fallback — the same stale-reading + // category already fixed for ride wait times (parksapi #316). + test('explicit OPEN/RIDE_NOW IS clock-gated: closed park overrides a live-looking status', () => { + expect(mapUniversalShowStatus('OPEN', false, false)).toBe('CLOSED'); + expect(mapUniversalShowStatus('RIDE_NOW', false, false)).toBe('CLOSED'); + }); + + test('delay/long-closure signals are NOT clock-gated — neither claims OPERATING', () => { + expect(mapUniversalShowStatus('BRIEF_DELAY', true, false)).toBe('DOWN'); + expect(mapUniversalShowStatus('EXTENDED_CLOSURE', true, false)).toBe('CLOSED'); + }); }); diff --git a/src/parks/universal/__tests__/showClockGate.test.ts b/src/parks/universal/__tests__/showClockGate.test.ts new file mode 100644 index 000000000..85803f5f8 --- /dev/null +++ b/src/parks/universal/__tests__/showClockGate.test.ts @@ -0,0 +1,420 @@ +/** + * Regression for programme#86 ("USH: 25 shows never leave OPERATING, so the + * rows never get written and read as stale"). + * + * show-list.json's show_times[] lists the WHOLE day's ENABLED performances + * from midnight, so `hasFutureShowtimes` (parseShowTimes) stays true all + * night once the feed rolls to the next operating day — a show sampled at + * 03:00 with the park shut for hours still has a slot hours away. Before the + * fix, mapUniversalShowStatus's CLOSED/CANCELED/unknown default branch read + * that as OPERATING with no reference to park hours, so the status (and + * therefore the wiki row) never changed across the overnight closure and + * `lastUpdated` froze. + * + * These tests drive the full buildLiveData → getLiveData pipeline (not just + * the pure mapUniversalShowStatus helper) so the venue-schedule lookup and + * resolveScheduleVenue wiring are covered too, using UniversalStudios (a + * single-park resort — venue place_id 'ush.ush', legacy venue id '13825') + * to match the reported incident directly. + */ +import {describe, test, expect, vi, afterEach} from 'vitest'; +import {UniversalStudios, UniversalOrlando, type UniversalShowListEntry} from '../universal.js'; + +// Real wall-clock hours from the incident: EXTRA_HOURS 08:00-09:00 PDT, +// general open 09:00-19:00 PDT. All offsets are -07:00 (Pacific, no DST +// re-projection needed since these are already Pacific-local strings for +// this fixture — see isParkOperatingNow, which compares absolute instants). +const USH_SCHEDULE_FIXTURE = [ + { + Date: '2026-08-18', + VenueStatus: 'Open', + OpenTimeString: '2026-08-18T09:00:00-07:00', + CloseTimeString: '2026-08-18T19:00:00-07:00', + EarlyEntryString: '2026-08-18T08:00:00-07:00', + }, +]; + +const SHOW: UniversalShowListEntry = { + show_id: 'ush.cw.entertainment.meet_mario_and_luigi', + resort_area_code: 'USH', + venue_id: 'ush.ush', + name: 'Meet Mario and Luigi', + // Not OPEN/RIDE_NOW/a delay/an explicit long closure — falls into the + // default branch that used to ignore park hours entirely. + status: 'CLOSED', + show_externally: true, + show_times: [ + // "Today's" full day of slots, as the feed actually serves it from + // midnight — some already past by any of the sampled times below, one + // still ahead of both. + {show_time_id: 'a', status: 'ENABLED', start_time: '2026-08-18T16:30:00.000Z'}, // 09:30 PDT + {show_time_id: 'b', status: 'ENABLED', start_time: '2026-08-19T01:00:00.000Z'}, // 18:00 PDT + ], +}; + +function stubPark( + park: T, + showList: UniversalShowListEntry[], + scheduleByVenueId: Record = {'13825': USH_SCHEDULE_FIXTURE}, +): T { + (park as any)._init = async () => undefined; + (park as any).getWaitTimes = async () => []; + (park as any).getVirtualQueueStates = async () => []; + (park as any).getShowList = async () => showList; + // venueId-aware, unlike a fixed return value: needed for UOR (4 distinct + // legacy venue ids sharing one buildLiveData call) to prove each park + // gates independently rather than all sharing whatever the stub returns. + (park as any).getVenueSchedule = async (venueId: string) => { + if (!(venueId in scheduleByVenueId)) throw new Error(`no schedule fixture stubbed for venue ${venueId}`); + return scheduleByVenueId[venueId]; + }; + return park; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Universal buildLiveData — show status clock-gated against park hours', () => { + test('overnight, park shut: show reads CLOSED even though a slot is still hours away', async () => { + // 03:00 PDT, 2026-08-18 — before EarlyEntryString (08:00 PDT). This is + // the incident window: park closed since ~19:12 PDT the prior evening, + // both show_times slots technically "in the future" relative to now. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); + + const park = stubPark(new UniversalStudios(), [SHOW]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry).toBeDefined(); + expect(entry!.status).toBe('CLOSED'); // was 'OPERATING' before the fix + }); + + test('during EXTRA_HOURS, park open: the same show reads OPERATING', async () => { + // 08:25 PDT, 2026-08-18 — inside EarlyEntryString..OpenTimeString. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T15:25:00.000Z')); + + const park = stubPark(new UniversalStudios(), [SHOW]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry).toBeDefined(); + expect(entry!.status).toBe('OPERATING'); + }); + + test('during general operating hours, park open: the same show reads OPERATING', async () => { + // 12:00 PDT, 2026-08-18 — well inside OpenTimeString..CloseTimeString. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T19:00:00.000Z')); + + const park = stubPark(new UniversalStudios(), [SHOW]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry).toBeDefined(); + expect(entry!.status).toBe('OPERATING'); + }); + + // Live evidence, not just theory: sampled at 03:24 PDT with USH's own + // schedule confirming the park shut, 25 of 31 externally-shown show-list + // entries carried `status: "OPEN"` outright. The status field itself is + // stale overnight, same as the ride wait-time feed (parksapi #316), so an + // explicit OPEN is gated exactly like the showtimes-derived default. + test('explicit OPEN status IS clock-gated: closed park overrides it', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, park shut + + const openShow: UniversalShowListEntry = {...SHOW, status: 'OPEN'}; + const park = stubPark(new UniversalStudios(), [openShow]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('CLOSED'); // was 'OPERATING' before the fix — the observed incident + }); + + test('explicit OPEN status during EXTRA_HOURS: park open, reads OPERATING', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T15:25:00.000Z')); // 08:25 PDT, EXTRA_HOURS + + const openShow: UniversalShowListEntry = {...SHOW, status: 'OPEN'}; + const park = stubPark(new UniversalStudios(), [openShow]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('OPERATING'); + }); + + test('venue schedule lookup failure degrades to ungated (old behaviour), not a thrown error', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, park shut + + const park = stubPark(new UniversalStudios(), [SHOW]); + (park as any).getVenueSchedule = async () => { throw new Error('upstream 500'); }; + + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('OPERATING'); + }); + + test('a show at CityWalk (no schedule-bearing venue) is not gated', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, USH park shut + + const cityWalkShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'ush.cw.entertainment.5_towers_stage', + venue_id: 'ush.cw', + }; + const park = stubPark(new UniversalStudios(), [cityWalkShow]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.5_towers_stage'); + + expect(entry!.status).toBe('OPERATING'); // hours unknown -> ungated, same as before the fix + }); + + // resolveScheduleVenue's reparenting branch (NON_SURFACED_VENUE_PARENT), + // driven end-to-end through buildLiveData rather than just unit-tested in + // isolation: a show whose venue_id is the sub-area 'ush.upper_lot' must + // gate against the SURFACED park's schedule ('ush.ush' / legacy 13825), + // not go ungated for lack of a direct match. + test('a show at Upper Lot reparents onto ush.ush\'s schedule (not ungated)', async () => { + const upperLotShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'ush.upper_lot.shows.meet_dracula', + venue_id: 'ush.upper_lot', + }; + + // Overnight, ush.ush schedule says shut -> reparented show must gate CLOSED. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); + let park = stubPark(new UniversalStudios(), [upperLotShow]); + let liveData = await park.getLiveData(); + let entry = liveData.find((d) => d.id === 'ush.upper_lot.shows.meet_dracula'); + expect(entry!.status).toBe('CLOSED'); + vi.useRealTimers(); + + // Inside ush.ush's operating window -> the same reparented show is OPERATING. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T19:00:00.000Z')); + park = stubPark(new UniversalStudios(), [upperLotShow]); + liveData = await park.getLiveData(); + entry = liveData.find((d) => d.id === 'ush.upper_lot.shows.meet_dracula'); + expect(entry!.status).toBe('OPERATING'); + }); + + // The `parkOperatingByVenue.get(scheduleVenue) ?? true` fail-open branch: + // resolveScheduleVenue passes an unrecognised (but non-null) venue_id + // through as-is, and it simply never appears as a key in the venue map + // built from PARK_PLACE_ID_TO_LEGACY_VENUE_ID — must fall open (ungated), + // not throw and not silently resolve to closed. + test('a show at an unrecognised venue_id (no PARK_PLACE_ID_TO_LEGACY_VENUE_ID entry) falls open, ungated', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, USH's real park shut + + const mysteryVenueShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'ush.some_new_area.shows.mystery_show', + venue_id: 'ush.some_new_area', // not in NON_SURFACED_VENUE_PARENT, not a surfaced park key + }; + const park = stubPark(new UniversalStudios(), [mysteryVenueShow]); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.some_new_area.shows.mystery_show'); + + expect(entry!.status).toBe('OPERATING'); // hours unknown -> ungated + }); + + test('a non-array schedule response does not throw and degrades to ungated', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, park shut + + const park = stubPark(new UniversalStudios(), [SHOW]); + (park as any).getVenueSchedule = async () => ({error: 'not found', problem: 'VENUE_NOT_FOUND'}); + + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('OPERATING'); + }); + + test('an empty schedule array degrades to ungated (indistinguishable from an upstream glitch)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, park shut + + const park = stubPark(new UniversalStudios(), [SHOW]); + (park as any).getVenueSchedule = async () => []; + + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('OPERATING'); + }); + + test('a schedule where every day is explicitly Closed is a confident CLOSED, not a fail-open', async () => { + // Distinguishes "we have real data and it says closed" (Volcano Bay's + // off-season) from "we have no usable data" (the empty-array case + // above) — both must not throw, but only the latter should fail open. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T19:00:00.000Z')); // would be OPERATING hours if open + + const park = stubPark(new UniversalStudios(), [SHOW]); + (park as any).getVenueSchedule = async () => [ + {Date: '2026-08-18', VenueStatus: 'Closed'}, + ]; + + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('CLOSED'); + }); + + test('a schedule day with no VenueStatus field at all still gates correctly (real USH shape has none)', async () => { + // src/parks/universal/gentype/UniversalStudios.fetchVenueSchedule.ts — + // real USH captures never include VenueStatus at all, unlike UOR's + // fixture. Prove the openMs/closeMs-only path works without it. + const noStatusFixture = [ + { + Date: '2026-08-18', + OpenTimeString: '2026-08-18T09:00:00-07:00', + CloseTimeString: '2026-08-18T19:00:00-07:00', + EarlyEntryString: '2026-08-18T08:00:00-07:00', + }, + ]; + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z')); // overnight, before EarlyEntry + let park = stubPark(new UniversalStudios(), [SHOW], {'13825': noStatusFixture}); + let liveData = await park.getLiveData(); + let entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + expect(entry!.status).toBe('CLOSED'); + vi.useRealTimers(); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T19:00:00.000Z')); // inside the window + park = stubPark(new UniversalStudios(), [SHOW], {'13825': noStatusFixture}); + liveData = await park.getLiveData(); + entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + expect(entry!.status).toBe('OPERATING'); + }); + + test('multi-day schedule: matches the correct day, not just the first entry', async () => { + // Two days with DIFFERENT hours; `now` only falls inside the second + // day's window. A bug that only checked schedule[0] would wrongly gate + // this CLOSED. + const multiDayFixture = [ + { + Date: '2026-08-17', + VenueStatus: 'Open', + OpenTimeString: '2026-08-17T09:00:00-07:00', + CloseTimeString: '2026-08-17T17:00:00-07:00', // closes well before `now` below + }, + { + Date: '2026-08-18', + VenueStatus: 'Open', + OpenTimeString: '2026-08-18T09:00:00-07:00', + CloseTimeString: '2026-08-18T23:00:00-07:00', + }, + ]; + + // SHOW's fixed show_times are both in the past by this point in the + // month, which would fail hasFutureShowtimes regardless of gating — use + // a show with a slot still ahead of this test's `now`. + const lateShow: UniversalShowListEntry = { + ...SHOW, + show_times: [ + {show_time_id: 'c', status: 'ENABLED', start_time: '2026-08-19T04:30:00.000Z'}, + ], + }; + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T04:00:00.000Z')); // 2026-08-18T21:00 PDT — only day 2 covers this + const park = stubPark(new UniversalStudios(), [lateShow], {'13825': multiDayFixture}); + const liveData = await park.getLiveData(); + const entry = liveData.find((d) => d.id === 'ush.cw.entertainment.meet_mario_and_luigi'); + + expect(entry!.status).toBe('OPERATING'); + }); + + // Universal Orlando: 4 parks sharing one buildLiveData call, each with its + // own legacy venue id and independently computed operating state. Proves + // the resortKey-filtered venue loop and per-venue gating both work when + // more than one venue is in play at once — UOR was entirely untested + // before this (only single-park UniversalStudios was exercised above). + describe('Universal Orlando — multiple parks gated independently in one cycle', () => { + const UOR_OPEN_SCHEDULE = [ + { + Date: '2026-08-18', + VenueStatus: 'Open', + OpenTimeString: '2026-08-18T09:00:00-04:00', + CloseTimeString: '2026-08-18T21:00:00-04:00', + }, + ]; + const UOR_CLOSED_SCHEDULE = [ + {Date: '2026-08-18', VenueStatus: 'Closed'}, + ]; + + test('a show at USF (open) reads OPERATING while a show at IOA (closed) reads CLOSED, same cycle', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T19:00:00.000Z')); // 15:00 EDT — inside USF's window + + const usfShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'uor.usf.shows.bourne_stuntacular', + venue_id: 'uor.usf', + }; + const ioaShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'uor.ioa.shows.frog_choir', + venue_id: 'uor.ioa', + }; + + const park = stubPark(new UniversalOrlando(), [usfShow, ioaShow], { + '10010': UOR_OPEN_SCHEDULE, // uor.usf + '10000': UOR_CLOSED_SCHEDULE, // uor.ioa + '24000': UOR_CLOSED_SCHEDULE, // uor.eu + '13801': UOR_CLOSED_SCHEDULE, // uor.vb + }); + + const liveData = await park.getLiveData(); + const usfEntry = liveData.find((d) => d.id === 'uor.usf.shows.bourne_stuntacular'); + const ioaEntry = liveData.find((d) => d.id === 'uor.ioa.shows.frog_choir'); + + expect(usfEntry!.status).toBe('OPERATING'); + expect(ioaEntry!.status).toBe('CLOSED'); + }); + + test('one UOR park\'s schedule-fetch failure does not corrupt gating for the others', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T19:00:00.000Z')); + + const usfShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'uor.usf.shows.bourne_stuntacular', + venue_id: 'uor.usf', + }; + const ioaShow: UniversalShowListEntry = { + ...SHOW, + show_id: 'uor.ioa.shows.frog_choir', + venue_id: 'uor.ioa', + }; + + const park = stubPark(new UniversalOrlando(), [usfShow, ioaShow], { + '10010': UOR_OPEN_SCHEDULE, // uor.usf — healthy + '24000': UOR_CLOSED_SCHEDULE, // uor.eu + '13801': UOR_CLOSED_SCHEDULE, // uor.vb + // '10000' (uor.ioa) deliberately unstubbed -> stubPark's fixture + // throws "no schedule fixture stubbed for venue 10000", exercising + // isParkOperatingNow's own catch block for that one venue only. + }); + + const liveData = await park.getLiveData(); + const usfEntry = liveData.find((d) => d.id === 'uor.usf.shows.bourne_stuntacular'); + const ioaEntry = liveData.find((d) => d.id === 'uor.ioa.shows.frog_choir'); + + expect(usfEntry!.status).toBe('OPERATING'); // unaffected by IOA's failure + expect(ioaEntry!.status).toBe('OPERATING'); // IOA's own lookup failed -> ungated, not crashed + }); + }); +}); diff --git a/src/parks/universal/universal.ts b/src/parks/universal/universal.ts index 1e7c53d1d..8ac0839b2 100644 --- a/src/parks/universal/universal.ts +++ b/src/parks/universal/universal.ts @@ -152,6 +152,27 @@ const NON_SURFACED_VENUE_PARENT: Record = { 'uor.cw': null, }; +/** + * Resolve a show-list entry's `venue_id` to the schedule-bearing park + * place_id used as a key into PARK_PLACE_ID_TO_LEGACY_VENUE_ID (e.g. + * 'ush.upper_lot' -> 'ush.ush'), for clock-gating show status against park + * hours. Mirrors the reparenting placeToEntity applies to child entities. + * Returns null only for a missing venue_id, or a venue explicitly mapped to + * null in NON_SURFACED_VENUE_PARENT (CityWalk — no schedule to check + * against). Any other venue_id passes through sanitized as-is, whether or + * not it's actually a surfaced park; a value that isn't a real key in + * PARK_PLACE_ID_TO_LEGACY_VENUE_ID simply misses the lookup at the call + * site and falls back to "hours unknown", same end result as null. + */ +function resolveScheduleVenue(venueId: string | undefined): string | null { + if (!venueId) return null; + const venue = sanitizeId(venueId); + if (venue in NON_SURFACED_VENUE_PARENT) { + return NON_SURFACED_VENUE_PARENT[venue]; + } + return venue; +} + /** 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; @@ -298,15 +319,36 @@ export function parseShowTimes( * live schedule. Explicit long closures (EXTENDED_CLOSURE / COMING_SOON) still * win over stray showtimes. Delay states stay DOWN — DOWN + showtimes is * coherent (interrupted but scheduled). + * + * `parkOperating` clock-gates every path that would otherwise resolve to + * OPERATING. Two independent things stay stale straight through an + * overnight closure, and both were observed live at USH (programme#86), + * not just theorised: + * - `show_times` lists the *whole day's* ENABLED performances from + * midnight, so "has a future slot" (`hasFutureShowtimes`) stays true + * all night once the feed rolls to the next operating day. + * - The `status` field itself is not reliably live either. Sampled at + * 03:24 PDT with USH's own schedule confirming the park shut, 25 of + * 31 externally-shown entries carried `status: "OPEN"` outright — the + * same category of stale-reading bug already fixed for the ride + * wait-time feed (parksapi #316), just on the status field instead of + * a queue reading. An explicit `OPEN`/`RIDE_NOW` is therefore trusted + * only while the park is actually open. + * Long-closure (EXTENDED_CLOSURE / COMING_SOON) and delay (BRIEF_DELAY / + * WEATHER_DELAY / AT_CAPACITY) signals are NOT gated — neither claims the + * show is operating, so there is nothing for the clock to override. + * Default true (ungated) when hours are unknown — callers pass false only + * when a schedule lookup positively confirms the park shut. */ export function mapUniversalShowStatus( status: string | undefined, hasFutureShowtimes = false, + parkOperating = true, ): 'OPERATING' | 'DOWN' | 'CLOSED' { switch (status) { case 'OPEN': case 'RIDE_NOW': - return 'OPERATING'; + return parkOperating ? 'OPERATING' : 'CLOSED'; case 'BRIEF_DELAY': case 'WEATHER_DELAY': case 'AT_CAPACITY': @@ -317,8 +359,8 @@ export function mapUniversalShowStatus( return 'CLOSED'; default: // CLOSED / CANCELED / unknown: operating today iff it still lists future - // ENABLED performances, otherwise CLOSED. - return hasFutureShowtimes ? 'OPERATING' : 'CLOSED'; + // ENABLED performances AND the park is actually open right now. + return (hasFutureShowtimes && parkOperating) ? 'OPERATING' : 'CLOSED'; } } @@ -410,15 +452,26 @@ export function parseExpressNowResponse(data: unknown): Record; @config @@ -905,6 +958,76 @@ class Universal extends Destination { return await resp.json(); } + /** + * Is the park behind `legacyVenueId` open right now — inside today's + * EXTRA_HOURS or OPERATING window per the legacy schedule endpoint? + * + * OpenTimeString/CloseTimeString/EarlyEntryString all carry a real UTC + * offset (the API stamps everything Eastern, even for Hollywood — see + * buildSchedules), so `new Date(...)` gives a directly comparable instant + * with no re-projection needed. Scanning every returned day rather than + * matching on the `Date` field sidesteps any day-boundary ambiguity + * around midnight. + * + * Returns true (ungated) whenever the schedule can't be trusted — a + * rejected fetch, a non-array response, or an array where not one single + * day parses into a usable window (empty array, every day malformed). + * Those are indistinguishable from an upstream glitch and must not + * silently start marking every show CLOSED. Only a response containing at + * least one genuinely parseable day, with none of them covering `now`, + * counts as a confirmed "the park is shut" — that is the ordinary, + * expected overnight case. + * + * Known gap, not yet acted on: does NOT check `SpecialEntryUnix`. It's a + * real field on every returned day (both resorts), but has been 0 on + * every day observed so far across ~11 weeks, including deep into a + * would-be Halloween Horror Nights window — its meaning is unconfirmed, + * so nothing is built on an unverified guess. If a ticketed after-hours + * event's showtimes turn out not to be covered by this endpoint at all, + * shows tied to that event would be wrongly gated CLOSED; needs a live + * check once such an event is actually running. + */ + async isParkOperatingNow(legacyVenueId: string, now: Date): Promise { + try { + const schedule = await this.getVenueSchedule(legacyVenueId); + if (!Array.isArray(schedule)) { + console.warn(`Universal: venue schedule for ${legacyVenueId} was not an array, unable to clock-gate shows`); + return true; + } + + const nowMs = now.getTime(); + // A day we could make a confident call on — either a real open/close + // window, or an explicit "Closed" (e.g. Volcano Bay's off-season). + // Both count. Only a day with neither (garbage / missing fields) does + // not, so a schedule that is EXPLICITLY closed every day still yields + // a confident `false` rather than failing open. + let sawValidDay = false; + for (const day of schedule) { + if (!day) continue; + if (day.VenueStatus === 'Closed') { + sawValidDay = true; + continue; + } + const openMs = new Date(day.EarlyEntryString || day.OpenTimeString || NaN).getTime(); + const closeMs = new Date(day.CloseTimeString || NaN).getTime(); + if (!Number.isFinite(openMs) || !Number.isFinite(closeMs)) continue; + sawValidDay = true; + if (nowMs >= openMs && nowMs <= closeMs) return true; + } + + if (!sawValidDay) { + console.warn(`Universal: venue schedule for ${legacyVenueId} had no usable day, unable to clock-gate shows`); + return true; + } + return false; + } catch (err: any) { + console.warn( + `Universal: venue schedule unavailable for ${legacyVenueId}, unable to clock-gate shows: ${err?.message ?? err}`, + ); + return true; + } + } + /** * Get destination entity */ @@ -1141,14 +1264,31 @@ class Universal extends Destination { } // Process show times from the CDN show-list.json (place_id-keyed). + // + // Whether each surfaced park is open right now, keyed by sanitized place + // id (e.g. 'ush.ush'). Computed once per cycle and shared across every + // show at that venue — clock-gates the "has future showtimes" default in + // mapUniversalShowStatus so a show doesn't read OPERATING straight + // through an overnight closure just because today's slot list is never + // empty (see mapUniversalShowStatus doc comment). const now = new Date(); + const parkOperatingByVenue = new Map(); + for (const [placeId, legacyVenueId] of Object.entries(PARK_PLACE_ID_TO_LEGACY_VENUE_ID)) { + if (!placeId.startsWith(`${this.resortKey}.`)) continue; + parkOperatingByVenue.set(sanitizeId(placeId), await this.isParkOperatingNow(legacyVenueId, now)); + } + for (const show of showList) { if (!show.show_externally) continue; const showId = sanitizeId(show.show_id); const showEntry = getOrCreateLiveData(showId); const times = parseShowTimes(show, this.timezone, now); - showEntry.status = mapUniversalShowStatus(show.status, times.length > 0); + const scheduleVenue = resolveScheduleVenue(show.venue_id); + // No resolvable venue (CityWalk, or a missing/unrecognised venue_id) -> + // hours unknown -> don't gate, same as a failed schedule lookup. + const parkOperating = scheduleVenue ? (parkOperatingByVenue.get(scheduleVenue) ?? true) : true; + showEntry.status = mapUniversalShowStatus(show.status, times.length > 0, parkOperating); if (times.length > 0) { showEntry.showtimes = times; } @@ -1222,6 +1362,10 @@ class Universal extends Destination { for (const daySchedule of venueSchedule) { if (daySchedule.VenueStatus === 'Closed') continue; + // UOR's real API omits Open/CloseTimeString on some days (e.g. an + // off-season closure) without necessarily setting VenueStatus — + // skip rather than publish an Invalid Date schedule entry. + if (!daySchedule.OpenTimeString || !daySchedule.CloseTimeString) continue; // The API server lives in Orlando and stamps every entry with the // Eastern offset — including Hollywood venues. Re-project into the