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[],