From ac1f1a6631e89997b8e5ce854f4901930a9a8667 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Fri, 30 Oct 2026 06:50:51 +0000 Subject: [PATCH] fix(europapark): report rides the feed drops overnight as CLOSED Around midnight local time the waiting-times feed stops listing rides for the main park, Rulantica and Traumatica, keeping only restaurants and the virtual-queue entries. The rides reappear one by one before opening, so from midnight to early morning about a third of the destination's live entities vanished from live data instead of reading CLOSED. Rides the feed listed in the last 7 days are now remembered. While a ride's own park is outside its published hours (main park: live calendar, falling back to seasons; other parks: seasons, from yesterday so a post-midnight close counts), a remembered ride the feed no longer lists is reported CLOSED. Rides the feed does list are never touched, an absent ride inside published hours is still left out, and nothing is carried when a park's hours cannot be read. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/overnightRideDrop.test.ts | 262 ++++++++++++++++++ src/parks/europapark/europapark.ts | 154 +++++++++- 2 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/parks/europapark/__tests__/overnightRideDrop.test.ts diff --git a/src/parks/europapark/__tests__/overnightRideDrop.test.ts b/src/parks/europapark/__tests__/overnightRideDrop.test.ts new file mode 100644 index 000000000..b0dca671b --- /dev/null +++ b/src/parks/europapark/__tests__/overnightRideDrop.test.ts @@ -0,0 +1,262 @@ +/** + * Rides the waiting-times feed drops overnight. + * + * Around midnight local time the feed stops listing rides (main park, + * Rulantica and Traumatica alike) and keeps only restaurants and the + * virtual-queue entries; the rides reappear one by one before opening. Seen + * upstream every night from 2026-10-27: 151 live entities by day, 102 from + * 00:00 to about 06:00 local. Without a carry-forward the dropped rides vanish + * from live data for the night. + * + * Fixtures are the real 2026-09-28 payloads used by closedDayLiveStatus.test.ts. + * The night feed is derived from them by removing every ride entry, which is + * the shape the feed has overnight. + */ +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'; +import { + EuropaPark, + RECENT_RIDES_KEY, + RECENT_RIDES_TTL_SECONDS, + rememberRides, + insideMainParkHours, + insideSeasonHours, +} from '../europapark.js'; +import {CacheLib} from '../../../cache.js'; + +const fixture = (name: string): any => + JSON.parse(readFileSync(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), 'utf8')); + +const WAITS = fixture('waiting-times-2026-09-28.json') as any[]; +const CALENDAR = fixture('live-calendar-2026-09-28.json'); +const SEASONS = fixture('seasons-2026-09-28.json') as any[]; +const ENTITY_ROWS = fixture('entities-2026-09-28.json') as any[]; +const GASTRONOMY = fixture('gastronomy-2026-09-28.json') as any[]; + +const ENTITIES = ENTITY_ROWS.map((e) => ({ + ...e, + vQueue: e.vq !== undefined ? {code: e.vq, queueing: true} : undefined, +})); +const POIS = GASTRONOMY.map((g) => ({...g, type: 'gastronomy', name: `gastronomy ${g.id}`})); + +const EXPRESS_IDS = new Set(['pois_60', 'pois_61', 'pois_62', 'pois_395']); +const RIDE_CODES = new Set( + ENTITIES.filter((e) => e.entityType === 'ATTRACTION' && e.code).map((e) => e.code), +); +const VQ_CODES = new Set(ENTITIES.filter((e) => e.vq !== undefined).map((e) => e.vq)); + +/** Overnight feed: every ride entry removed; restaurants and VQ entries stay. */ +const NIGHT_WAITS = WAITS.filter((w) => !RIDE_CODES.has(w.code) || VQ_CODES.has(w.code)); + +// 14:00 local on 2026-09-28: main park (09:00-18:30) and Rulantica (09:30-22:00) open. +const DAY_NOW = new Date('2026-09-28T12:00:00Z'); +// 01:00 local on 2026-09-29: every park closed. +const NIGHT_NOW = new Date('2026-09-28T23:00:00Z'); +// 07:30 local on 2026-09-29: before opening, rides starting to reappear. +const MORNING_NOW = new Date('2026-09-29T05:30:00Z'); +// 20:00 local on 2026-09-28: main park closed, Rulantica still open. +const EVENING_NOW = new Date('2026-09-28T18:00:00Z'); + +/** The live calendar as served after midnight: `today` is the 29th. */ +const CALENDAR_29 = {...CALENDAR, today: CALENDAR.tomorrow, tomorrow: null}; + +const VOLETARIUM = {id: 'pois_346', code: 9}; +const RULANTICA_RIDE = {id: 'pois_14', code: 1001}; + +class LiveProbe extends EuropaPark { + constructor( + private readonly waits: any[], + private readonly calendar: () => Promise, + private readonly seasons: () => Promise = async () => SEASONS, + ) { + super(); + (this as any).hotelAppBase = ''; + } + override async getParkEntities(): Promise { return ENTITIES; } + override async getPOIs(): Promise { return POIS; } + override async getWaitingTimes(): Promise { return this.waits; } + override async getShowTimes(): Promise { return []; } + override async getLiveCalendar(): Promise { return this.calendar(); } + override async getSeasons(): Promise { return this.seasons(); } + async live(): Promise> { + const rows = await this.buildLiveData(); + return new Map(rows.map((r: any) => [r.id, r])); + } +} + +const failing = (what: string) => async (): Promise => { + throw new Error(`${what} HTTP 503`); +}; + +/** Ride rows (pois_* attractions, EP-Express excluded). */ +const rideIds = (live: Map): string[] => + [...live.keys()] + .filter((id) => id.startsWith('pois_') && !EXPRESS_IDS.has(id) + && ENTITIES.find((e) => e.id === id)?.entityType === 'ATTRACTION') + .sort(); + +async function tickAt(now: Date, waits: any[], calendar: () => Promise, seasons?: () => Promise) { + vi.setSystemTime(now); + return new LiveProbe(waits, calendar, seasons).live(); +} + +beforeEach(() => { + vi.useFakeTimers({toFake: ['Date']}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + CacheLib.delete(RECENT_RIDES_KEY); +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + CacheLib.delete(RECENT_RIDES_KEY); +}); + +describe('overnight ride drop', () => { + test('the derived night feed lists no rides', () => { + const rides = NIGHT_WAITS.filter((w) => RIDE_CODES.has(w.code) && !VQ_CODES.has(w.code)); + expect(rides).toEqual([]); + expect(NIGHT_WAITS.length).toBeGreaterThan(0); + }); + + test('every ride seen by day is reported CLOSED overnight, none vanish', async () => { + const day = await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const dayRides = rideIds(day); + expect(dayRides.length).toBeGreaterThan(40); + + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, async () => CALENDAR_29); + expect(rideIds(night)).toEqual(dayRides); + for (const id of dayRides) { + expect(night.get(id)).toEqual({id, status: 'CLOSED'}); + } + }); + + test('carries Rulantica and Traumatica rides as well as main-park rides', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, async () => CALENDAR_29); + const scopes = new Set(rideIds(night).flatMap((id) => ENTITIES.find((e) => e.id === id)!.scopes)); + expect(scopes).toEqual(new Set(['europapark', 'resort', 'rulantica', 'traumatica'])); + expect(night.get(RULANTICA_RIDE.id)).toEqual({id: RULANTICA_RIDE.id, status: 'CLOSED'}); + }); + + test('restaurants overnight are unchanged by the carry-forward', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, async () => CALENDAR_29); + CacheLib.delete(RECENT_RIDES_KEY); + const cold = await tickAt(NIGHT_NOW, NIGHT_WAITS, async () => CALENDAR_29); + const gastro = (m: Map) => [...m.entries()].filter(([id]) => id.startsWith('gastronomy_')); + expect(gastro(night)).toEqual(gastro(cold)); + }); + + test('morning ramp: a ride the feed lists again keeps its reported status', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const refurb = WAITS.find((w) => w.code === VOLETARIUM.code)!; + const morningWaits = [...NIGHT_WAITS, {...refurb, time: 222}]; + const morning = await tickAt(MORNING_NOW, morningWaits, async () => CALENDAR_29); + expect(morning.get(VOLETARIUM.id)?.status).toBe('REFURBISHMENT'); + const others = rideIds(morning).filter((id) => id !== VOLETARIUM.id); + expect(others.length).toBeGreaterThan(40); + for (const id of others) expect(morning.get(id)?.status).toBe('CLOSED'); + }); + + test('inside published hours an absent ride is still left out', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const waits = WAITS.filter((w) => w.code !== VOLETARIUM.code); + const later = await tickAt(new Date('2026-09-28T12:05:00Z'), waits, async () => CALENDAR); + expect(later.has(VOLETARIUM.id)).toBe(false); + }); + + test('each park uses its own hours: Rulantica open while the main park is closed', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const waits = WAITS.filter((w) => w.code !== VOLETARIUM.code && w.code !== RULANTICA_RIDE.code); + const evening = await tickAt(EVENING_NOW, waits, async () => CALENDAR); + expect(evening.get(VOLETARIUM.id)).toEqual({id: VOLETARIUM.id, status: 'CLOSED'}); + expect(evening.has(RULANTICA_RIDE.id)).toBe(false); + }); + + test('a ride the feed never listed is not invented', async () => { + const day = await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, async () => CALENDAR_29); + const neverListed = ENTITIES.filter((e) => e.entityType === 'ATTRACTION' && !day.has(e.id)); + expect(neverListed.length).toBeGreaterThan(0); + for (const e of neverListed) expect(night.has(e.id)).toBe(false); + }); + + test('no memory yet (first tick is at night): nothing is carried', async () => { + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, async () => CALENDAR_29); + expect(rideIds(night)).toEqual([]); + }); + + test('calendar and seasons both unavailable: nothing is carried', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, failing('calendar'), failing('seasons')); + expect(rideIds(night)).toEqual([]); + }); + + test('calendar unavailable: the main park falls back to the seasons feed', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const night = await tickAt(NIGHT_NOW, NIGHT_WAITS, failing('calendar')); + expect(night.get(VOLETARIUM.id)).toEqual({id: VOLETARIUM.id, status: 'CLOSED'}); + }); + + test('a ride not listed for over a week is forgotten', async () => { + await tickAt(DAY_NOW, WAITS, async () => CALENDAR); + const weekLater = new Date(NIGHT_NOW.getTime() + RECENT_RIDES_TTL_SECONDS * 1000); + const night = await tickAt(weekLater, NIGHT_WAITS, async () => null); + expect(night.has(VOLETARIUM.id)).toBe(false); + }); +}); + +describe('rememberRides', () => { + const NOW = 1_800_000_000_000; + test('adds seen ids at now and keeps recent ones', () => { + expect(rememberRides({a: NOW - 1000}, ['b'], NOW)).toEqual({a: NOW - 1000, b: NOW}); + }); + test('refreshes a seen id', () => { + expect(rememberRides({a: NOW - 1000}, ['a'], NOW)).toEqual({a: NOW}); + }); + test('drops ids older than the TTL', () => { + const old = NOW - RECENT_RIDES_TTL_SECONDS * 1000; + expect(rememberRides({a: old, b: old + 1}, [], NOW)).toEqual({b: old + 1}); + }); + test('ignores a malformed previous value', () => { + expect(rememberRides('x' as any, ['a'], NOW)).toEqual({a: NOW}); + expect(rememberRides({a: 'y'} as any, [], NOW)).toEqual({}); + expect(rememberRides(null, [], NOW)).toEqual({}); + }); +}); + +describe('insideMainParkHours', () => { + test('inside, before and after the published window', () => { + expect(insideMainParkHours(CALENDAR, '2026-09-28', new Date('2026-09-28T12:00:00Z'))).toBe(true); + expect(insideMainParkHours(CALENDAR, '2026-09-28', new Date('2026-09-28T06:59:59Z'))).toBe(false); + expect(insideMainParkHours(CALENDAR, '2026-09-28', new Date('2026-09-28T16:30:00Z'))).toBe(false); + }); + test('a closed day is outside hours', () => { + const closed = {...CALENDAR, today: {date: '2026-09-28T00:00:00+02:00', start: null, end: null}}; + expect(insideMainParkHours(closed, '2026-09-28', DAY_NOW)).toBe(false); + }); + test('unknown when missing, stale or malformed', () => { + expect(insideMainParkHours(null, '2026-09-28', DAY_NOW)).toBeNull(); + expect(insideMainParkHours(CALENDAR, '2026-09-29', DAY_NOW)).toBeNull(); + const bad = {...CALENDAR, today: {...CALENDAR.today, start: 'not a time'}}; + expect(insideMainParkHours(bad, '2026-09-28', DAY_NOW)).toBeNull(); + }); +}); + +describe('insideSeasonHours', () => { + const w = {openingTime: '2026-09-28T09:30:00+02:00', closingTime: '2026-09-28T22:00:00+02:00'}; + test('inside and outside a window, closing time exclusive', () => { + expect(insideSeasonHours([w], new Date('2026-09-28T18:00:00Z'))).toBe(true); + expect(insideSeasonHours([w], new Date('2026-09-28T20:00:00Z'))).toBe(false); + }); + test('no windows is outside hours; unparsable windows are ignored', () => { + expect(insideSeasonHours([], DAY_NOW)).toBe(false); + expect(insideSeasonHours([{openingTime: 'x', closingTime: 'y'}], DAY_NOW)).toBe(false); + }); + test('a window that closes after midnight still counts', () => { + const late = {openingTime: '2026-09-28T19:00:00+02:00', closingTime: '2026-09-29T01:00:00+02:00'}; + expect(insideSeasonHours([late], new Date('2026-09-28T22:30:00Z'))).toBe(true); + }); +}); + diff --git a/src/parks/europapark/europapark.ts b/src/parks/europapark/europapark.ts index 0ef96e52b..a422b5578 100644 --- a/src/parks/europapark/europapark.ts +++ b/src/parks/europapark/europapark.ts @@ -1,5 +1,5 @@ import {Destination, DestinationConstructor} from '../../destination.js'; -import {cache} from '../../cache.js'; +import {cache, CacheLib} from '../../cache.js'; import {http, HTTPObj} from '../../http.js'; import {inject} from '../../injector.js'; import {reusable} from '../../promiseReuse.js'; @@ -93,7 +93,7 @@ type EuropaParkSeason = { }; /** Live calendar overlay for today */ -type EuropaParkLiveCalendar = { +export type EuropaParkLiveCalendar = { today?: { date: string; start: string | null; @@ -185,6 +185,81 @@ const EP_EXPRESS_ENTITY_IDS = new Set( Object.values(EP_EXPRESS_STATION_TO_POI).map((poiId) => `pois_${poiId}`), ); +// ─── Overnight ride carry-forward ───────────────────────────────────────────── +// +// Around midnight local time the waiting-times feed stops listing the main +// park's rides and keeps only restaurants; the rides reappear one by one before +// opening. Rulantica's and Traumatica's rides drop the same way. A ride the feed +// dropped would otherwise vanish from live data for the night. Rides listed in +// the last week are remembered, and while a ride's own park is outside its +// published hours, a remembered ride the feed no longer lists is reported +// CLOSED. Inside published hours an absent ride is still left out, as before. + +/** Cache key for the rides the waiting-times feed listed recently. */ +export const RECENT_RIDES_KEY = 'europapark:recentRideIds'; + +/** How long a ride stays remembered after the feed last listed it. */ +export const RECENT_RIDES_TTL_SECONDS = 60 * 60 * 24 * 7; + +/** + * Merge the rides seen this tick into the remembered set (entity id → last seen, + * epoch ms) and drop any not seen within the TTL. Pure. + */ +export function rememberRides( + previous: Record | null | undefined, + seenIds: Iterable, + nowMs: number, +): Record { + const cutoff = nowMs - RECENT_RIDES_TTL_SECONDS * 1000; + const next: Record = {}; + if (previous && typeof previous === 'object') { + for (const [id, at] of Object.entries(previous)) { + if (typeof at === 'number' && Number.isFinite(at) && at > cutoff) next[id] = at; + } + } + for (const id of seenIds) next[id] = nowMs; + return next; +} + +/** + * Whether `now` falls inside the main park's published hours today, from the + * live calendar. `null` when the calendar cannot say (missing, malformed, or a + * `today` block for another day). A closed day is outside hours. Pure. + */ +export function insideMainParkHours( + calendar: EuropaParkLiveCalendar | null | undefined, + parkDate: string, + now: Date, +): boolean | null { + const today = calendar?.today; + if (!today || typeof today !== 'object' || typeof today.date !== 'string') return null; + if (today.date.substring(0, 10) !== parkDate) return null; + if (today.start === null || today.end === null) return false; + if (typeof today.start !== 'string' || typeof today.end !== 'string') return null; + const start = Date.parse(today.start); + const end = Date.parse(today.end); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + const t = now.getTime(); + return t >= start && t < end; +} + +/** + * Whether `now` falls inside any of a park's season opening windows. A park + * with no window covering `now` (including one with no season at all) is + * outside hours. Windows whose times cannot be parsed are ignored. Pure. + */ +export function insideSeasonHours( + windows: Array<{openingTime: string; closingTime: string}>, + now: Date, +): boolean { + const t = now.getTime(); + return windows.some((w) => { + const open = Date.parse(w.openingTime); + const close = Date.parse(w.closingTime); + return Number.isFinite(open) && Number.isFinite(close) && t >= open && t < close; + }); +} + // ─── Main class ─────────────────────────────────────────────────────────────── @config @@ -888,6 +963,8 @@ class EuropaParkBase extends Destination { // During the winter closure the waiting-times feed keeps listing the // main park's rides but sends `time: 0` instead of the closed code 333, // which the pass above maps to OPERATING with a 0-minute wait. + await this._carryForwardAbsentRides(liveDataMap, entities, liveCalendar, now); + await this._applyMainParkClosure(liveDataMap, entities, waits, liveCalendar, now); // ── Show times ──────────────────────────────────────────────────────── @@ -1030,6 +1107,79 @@ class EuropaParkBase extends Destination { * rides do run on some days the season calendar leaves out. If the seasons * feed is also unavailable, the feed mapping is left as it is. */ + /** + * Remember the rides the feed listed this tick, then report every remembered + * ride the feed no longer lists as CLOSED when its own park is outside + * published hours. Rides the feed does list are never touched, and a ride + * whose park's hours cannot be read is left out, as before. + */ + protected async _carryForwardAbsentRides( + liveDataMap: Map, + entities: EuropaParkEntity[], + calendar: EuropaParkLiveCalendar | null, + now: Date, + ): Promise { + const rideScope = new Map(); + for (const entity of entities) { + if (entity.entityType !== 'ATTRACTION' || EP_EXPRESS_ENTITY_IDS.has(entity.id)) continue; + const scope = this._rideScope(entity); + if (scope) rideScope.set(entity.id, scope); + } + const seen = [...rideScope.keys()].filter((id) => liveDataMap.has(id)); + + const remembered = rememberRides(CacheLib.get(RECENT_RIDES_KEY), seen, now.getTime()); + CacheLib.set(RECENT_RIDES_KEY, remembered, RECENT_RIDES_TTL_SECONDS); + + const absent = Object.keys(remembered).filter((id) => rideScope.has(id) && !liveDataMap.has(id)); + if (absent.length === 0) return; + + const openNow = await this._parkScopesOpenNow(calendar, now); + for (const id of absent) { + if (openNow.get(rideScope.get(id)!) !== false) continue; + liveDataMap.set(id, {id, status: 'CLOSED'} as LiveData); + } + } + + /** The park a ride belongs to: another park's scope wins over the main park's. */ + private _rideScope(entity: EuropaParkEntity): string | undefined { + const other = OTHER_PARK_SCOPES.find((scope) => entity.scopes.includes(scope)); + if (other) return other; + return entity.scopes.includes(MAIN_PARK_SCOPE) ? MAIN_PARK_SCOPE : undefined; + } + + /** + * Whether each park is inside its published hours right now: `true`, `false`, + * or `null` when that cannot be read. The main park uses the live calendar and + * falls back to the seasons feed; the other parks use the seasons feed, from + * yesterday on so a closing time after midnight still counts. + */ + protected async _parkScopesOpenNow( + calendar: EuropaParkLiveCalendar | null, + now: Date, + ): Promise> { + let seasons: EuropaParkSeason[] | null = null; + try { + const resp = await this.getSeasons(); + seasons = Array.isArray(resp) ? resp : null; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`Europa-Park seasons unavailable (${msg}); not carrying absent rides`); + } + + const result = new Map(); + for (const park of PARK_CONFIGS) { + let open: boolean | null = null; + if (park.scope === MAIN_PARK_SCOPE) { + open = insideMainParkHours(calendar, this._parkDate(now), now); + } + if (open === null && seasons) { + open = insideSeasonHours(this._seasonTimesForPark(seasons, park, addDays(now, -1)), now); + } + result.set(park.scope, open); + } + return result; + } + protected async _applyMainParkClosure( liveDataMap: Map, entities: EuropaParkEntity[],