From 67f3f8292c9e8a10f7f389683a62fce06acf8d46 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sat, 20 Jun 2026 21:24:52 +0000 Subject: [PATCH 1/9] feat(fantawild): add base class + Wuhu Dreamland (first chain park) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Fantawild (方特 / Fang Te) Chinese theme-park chain. The chain operates ~50 parks under a shared mobile app (`com.hytch.ftthemepark`) backed by two public-facing services: - `image.fangte.com` — unauthenticated CDN for park list, daily opening hours, announcements - `leyou.fangte.com` — anonymous-callable JSON API exposing the per-park ride list with live wait times, open/closed flags, maintenance text, show times, and per-ride coordinates Both services are reachable without bearer tokens or HMAC signing in practice, despite the captured app headers suggesting otherwise. Wuhu Fantawild Dreamland (parkId 19) ships as the proof park: 22 ATTRACTION + 5 SHOW entities, live wait times + REFURBISHMENT status, 10 days of operating-hours schedule. End-to-end against the real APIs. Co-Authored-By: Claude Opus 4.7 --- src/__tests__/entityIdRegression.test.ts | 15 + .../fantawild/__tests__/fantawild.test.ts | 208 +++++++++ src/parks/fantawild/fantawild.ts | 436 ++++++++++++++++++ src/parks/fantawild/wuhudreamland.ts | 20 + 4 files changed, 679 insertions(+) create mode 100644 src/parks/fantawild/__tests__/fantawild.test.ts create mode 100644 src/parks/fantawild/fantawild.ts create mode 100644 src/parks/fantawild/wuhudreamland.ts diff --git a/src/__tests__/entityIdRegression.test.ts b/src/__tests__/entityIdRegression.test.ts index a85a0797..5299f7c3 100644 --- a/src/__tests__/entityIdRegression.test.ts +++ b/src/__tests__/entityIdRegression.test.ts @@ -233,6 +233,21 @@ describe('Destination ID patterns', () => { expect(destinations[0]?.id).not.toBe('galvestonislandwaterpark'); }); + test('Wuhu Dreamland (Fantawild) is registered under the Fantawild umbrella', async () => { + const destinations = await getAllDestinations(); + const ids = destinations.map(d => d.id); + expect(ids).toContain('wuhudreamland'); + const wd = destinations.find(d => d.id === 'wuhudreamland'); + expect(wd?.category).toEqual(['Fantawild']); + }); + + test('Wuhu Dreamland emits fantawild-namespaced DESTINATION entity id', async () => { + const {WuhuDreamland} = await import('../parks/fantawild/wuhudreamland.js'); + const dest = new WuhuDreamland({}); + const destinations = await dest.getDestinations(); + expect(destinations[0]?.id).toBe('fantawild_wuhudreamland'); + }); + test('Attractions.io v1 Merlin parks are registered individually', async () => { const destinations = await getAllDestinations(); const merlin = destinations.filter(d => diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts new file mode 100644 index 00000000..e2a2afb0 --- /dev/null +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -0,0 +1,208 @@ +import {describe, test, expect} from 'vitest'; +import { + parseBusinessTime, + stripFantawildStars, + isFantawildShow, + type FantawildBusinessTimeResponse, + type FantawildItem, +} from '../fantawild.js'; + +const TZ = 'Asia/Shanghai'; + +describe('parseBusinessTime', () => { + test('maps a single activated day to an OPERATING entry', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-21 00:00:00', + startTime: '09:30', + endTime: '18:00', + isNight: false, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, + stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({date: '2026-06-21', type: 'OPERATING'}); + expect(out[0].openingTime).toBe('2026-06-21T09:30:00+08:00'); + expect(out[0].closingTime).toBe('2026-06-21T18:00:00+08:00'); + }); + + test('emits an EXTRA_HOURS entry alongside OPERATING when a night event is configured', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-21 00:00:00', + startTime: '09:30', + endTime: '21:00', + isNight: true, isMorrow: false, + nightStartTime: '15:00', nightEndTime: '21:00', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, + stopIntoPark: '20:30', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(2); + expect(out[0].type).toBe('OPERATING'); + expect(out[1].type).toBe('EXTRA_HOURS'); + expect(out[1].openingTime).toBe('2026-06-21T15:00:00+08:00'); + expect(out[1].closingTime).toBe('2026-06-21T21:00:00+08:00'); + }); + + test('skips deactivated entries', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-22 00:00:00', + startTime: '09:30', endTime: '18:00', + isNight: false, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: false, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, + stopIntoPark: '', + }], + }; + expect(parseBusinessTime(json, TZ)).toEqual([]); + }); + + test('skips entries with no start/end time even if activated (closed days)', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-23 00:00:00', + startTime: '', endTime: '', + isNight: false, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '休园', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, + stopIntoPark: '', + }], + }; + expect(parseBusinessTime(json, TZ)).toEqual([]); + }); + + test('skips entries with malformed currentDate', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [ + { + currentDate: 'tomorrow', + startTime: '09:30', endTime: '18:00', + isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }, + { + currentDate: '', + startTime: '09:30', endTime: '18:00', + isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }, + ], + }; + expect(parseBusinessTime(json, TZ)).toEqual([]); + }); + + test('does NOT emit EXTRA_HOURS when isNight is true but night times are empty', () => { + // Real fixture: API sometimes flips `isNight` flag on days without + // populating night times. Treat as a normal-hours day. + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-24 00:00:00', + startTime: '09:30', endTime: '18:00', + isNight: true, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(1); + expect(out[0].type).toBe('OPERATING'); + }); + + test('returns [] for null / undefined / empty payloads', () => { + expect(parseBusinessTime(null, TZ)).toEqual([]); + expect(parseBusinessTime(undefined, TZ)).toEqual([]); + expect(parseBusinessTime({key: 'k', value: []}, TZ)).toEqual([]); + }); + + test('processes a multi-day fixture in the order the API returns it', () => { + // The API doesn't sort by date — entries arrive in app-storage order. + // The parser should NOT re-sort; the destination's schedule renderer + // handles ordering downstream. + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [ + {currentDate: '2026-06-21 00:00:00', startTime: '09:30', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + {currentDate: '2026-06-27 00:00:00', startTime: '09:30', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + {currentDate: '2026-06-22 00:00:00', startTime: '09:30', endTime: '17:30', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + ], + }; + const out = parseBusinessTime(json, TZ); + expect(out.map(s => s.date)).toEqual(['2026-06-21', '2026-06-27', '2026-06-22']); + }); +}); + +describe('stripFantawildStars', () => { + test('strips trailing star glyphs from itemName', () => { + expect(stripFantawildStars('孟姜女⭐⭐️⭐️⭐')).toBe('孟姜女'); + expect(stripFantawildStars('伴你飞翔⭐⭐️⭐️⭐⭐')).toBe('伴你飞翔'); + expect(stripFantawildStars('女娲补天 ⭐⭐⭐⭐⭐')).toBe('女娲补天'); + }); + + test('returns name unchanged when there are no trailing stars', () => { + expect(stripFantawildStars('魔法城堡')).toBe('魔法城堡'); + expect(stripFantawildStars('Magic Castle')).toBe('Magic Castle'); + }); + + test('does NOT strip stars that appear mid-string', () => { + // Stars only at the END are decorations; an embedded star is part of the name. + expect(stripFantawildStars('Star⭐Show extra')).toBe('Star⭐Show extra'); + }); + + test('handles empty string', () => { + expect(stripFantawildStars('')).toBe(''); + }); +}); + +const baseItem = (overrides: Partial = {}): FantawildItem => ({ + parkId: 19, id: 1, itemName: 'Test', waitTime: 0, itemOpened: true, + statusStr: null, showTimeList: [], featureList: [], ...overrides, +}); + +describe('isFantawildShow', () => { + test('treats single time-range as RIDE (operating hours)', () => { + expect(isFantawildShow(baseItem({showTimeList: ['09:30-21:00']}))).toBe(false); + }); + + test('treats discrete-time list as SHOW', () => { + expect(isFantawildShow(baseItem({showTimeList: ['14:00', '15:30']}))).toBe(true); + expect(isFantawildShow(baseItem({showTimeList: ['10:30', '11:30', '12:30', '13:30']}))).toBe(true); + }); + + test('respects 真人表演 feature tag even with range-shaped times', () => { + expect(isFantawildShow(baseItem({ + showTimeList: ['09:00-21:00'], + featureList: ['真人表演', '观赏'], + }))).toBe(true); + }); + + test('respects 巡游 (parade) feature tag', () => { + expect(isFantawildShow(baseItem({ + showTimeList: [], + featureList: ['巡游', '亲子'], + }))).toBe(true); + }); + + test('returns false when showTimeList is empty and no explicit feature flag', () => { + expect(isFantawildShow(baseItem({showTimeList: [], featureList: ['亲子']}))).toBe(false); + }); + + test('handles mixed list (range + discrete) by treating it as RIDE', () => { + // If ANY entry is a range, lean toward attraction hours rather than show times. + expect(isFantawildShow(baseItem({ + showTimeList: ['09:45-12:15', '13:45', '14:15'], + }))).toBe(false); + }); +}); + diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts new file mode 100644 index 00000000..3a688183 --- /dev/null +++ b/src/parks/fantawild/fantawild.ts @@ -0,0 +1,436 @@ +/** + * Fantawild (方特 / Fang Te) — shared base class for all Fantawild theme parks. + * + * The Fantawild chain operates ~50 parks across China under several brand + * lines (Dreamland 梦幻王国, Oriental Heritage 东方神画, Adventure 欢乐世界, + * Boonie Bears 熊出没, Water Park 水上乐园, etc.). All parks share a common + * mobile app (`方特旅游`, package `com.hytch.ftthemepark`) which fetches + * static metadata from a shared CDN at `image.fangte.com`. + * + * Phase 1 (this file): destination + park entity + operating-hours schedule, + * sourced entirely from the unauthenticated CDN. No per-ride data. + * + * Phase 2 (future): ride list + live wait times require a bearer token from + * `leyou.fangte.com`; the REST path is not recoverable statically because the + * Dart binary (Flutter) didn't yield to blutter. Needs a runtime device + * capture to discover the endpoint. + * + * CDN routes used (all GET, no auth): + * /UploadFiles/Launch/CityPark/{cityParkVersion}.json + * - Master park list. The numeric `{version}` is a release pointer, not + * a parkId. Same content is served from multiple version numbers. + * /UploadFiles/Launch/BusinessTime/{businessTimeVersion}/{parkId}.json + * - Per-park daily opening hours, keyed by the small parkId (17, 19, …). + * /UploadFiles/Launch/Announcement/{businessTimeVersion}/{parkId}.json + * - Per-park announcements (rich-text URLs). + */ + +import {Destination, type DestinationConstructor} from '../../destination.js'; +import {http, type HTTPObj} from '../../http.js'; +import {cache} from '../../cache.js'; +import config from '../../config.js'; +import type {Entity, LiveData, EntitySchedule, ScheduleEntry} from '@themeparks/typelib'; +import {constructDateTime, formatInTimezone} from '../../datetime.js'; + +// ── API types ─────────────────────────────────────────────────────────────── + +export interface FantawildBusinessTimeEntry { + /** "YYYY-MM-DD HH:MM:SS" wall-clock in the park timezone */ + currentDate: string; + /** "HH:MM" — empty string when closed */ + startTime: string; + /** "HH:MM" — empty string when closed */ + endTime: string; + isNight: boolean; + isMorrow: boolean; + nightStartTime: string; + nightEndTime: string; + activated: boolean; + statusTips: string; + parkCloseDesc: string | null; + closeRemarkUrl: string | null; + remarkUrl: string | null; + /** Last entry time, "HH:MM" or empty */ + stopIntoPark: string; +} + +export interface FantawildBusinessTimeResponse { + key: string; + value: FantawildBusinessTimeEntry[]; + version?: string; +} + +export interface FantawildCityParkEntry { + parkName: string; + parkTypeName?: string; + picUrl?: string; + logoPicUrl?: string; + id: number; + poiId?: string; + poiCode?: number; + poiCategory?: number; + lngLong?: Array<{latitude: number; longitude: number; sort: number}>; + waitTimeAreaLngLong?: Array<{latitude: number; longitude: number; sort: number}>; + onLineChannelList?: number[]; +} + +export interface FantawildCityEntry { + id: number; + cityName: string; + cityNameSpell?: string; + cityCode?: string; + parkList: FantawildCityParkEntry[]; +} + +/** One ride/show entry from the `GetItemBusinessList` endpoint. */ +export interface FantawildItem { + parkId: number; + /** Numeric id stable across days; used to derive entity IDs. */ + id: number; + /** Display name. May contain trailing star-rating glyphs (⭐) that we strip. */ + itemName: string; + /** Live wait time in minutes. 0 when the park is closed or there's no queue. */ + waitTime: number; + /** Open/closed flag set by the app's data ops team. */ + itemOpened: boolean; + /** Free-form status string (e.g. `项目维护,暂停开放` = "under maintenance"). */ + statusStr: string | null; + longitude?: number; + latitude?: number; + /** + * Mixed-purpose. For attractions, typically a single "HH:MM-HH:MM" range of + * operating hours. For shows, a list of discrete "HH:MM" start times. We use + * this both to classify entity type and to surface SHOWTIMES. + */ + showTimeList?: string[]; + nextShowTimeList?: string[]; + heightStr?: string; + featureList?: string[]; + mainPic?: string; + recommendType?: number; + distanceStr?: string; +} + +export interface FantawildItemListResponse { + data?: FantawildItem[]; +} + +// ── Parsers ───────────────────────────────────────────────────────────────── + +/** Trailing star-rating glyphs Fantawild bakes into itemName (e.g. `孟姜女⭐⭐⭐⭐`). */ +const STAR_RE = /[⭐⭐️]+\s*$/u; + +/** Strip trailing star-rating glyphs from a Fantawild item name. */ +export function stripFantawildStars(name: string): string { + return name.replace(STAR_RE, '').trim(); +} + +/** Classify an item as SHOW vs RIDE based on showTimeList shape + feature tags. */ +export function isFantawildShow(item: FantawildItem): boolean { + const features = item.featureList ?? []; + // Explicit live-performance / parade feature flags. + if (features.includes('真人表演') || features.includes('巡游')) return true; + const times = item.showTimeList ?? []; + if (times.length === 0) return false; + // If every entry is a single time (no dash range) it's a discrete-showtime SHOW. + // A single "HH:MM-HH:MM" range is the operating-hours pattern used for attractions. + const allDiscrete = times.every(t => /^\d{1,2}:\d{2}$/.test(t.trim())); + return allDiscrete && times.length >= 1; +} + +// ── Schedule parser ───────────────────────────────────────────────────────── + +/** + * Convert a Fantawild BusinessTime response to ScheduleEntry[]. + * + * Pure module-level function so it can be unit-tested without the destination + * harness. Skips entries that aren't `activated`, that have no `startTime`, + * or whose date can't be parsed. Adds an EXTRA_HOURS entry when `isNight` + * is true and night times are populated — the park's day session has its + * own startTime/endTime, and the night session (e.g. fireworks/dark-ride + * event) is layered on top. + */ +export function parseBusinessTime( + json: FantawildBusinessTimeResponse | null | undefined, + timezone: string, +): ScheduleEntry[] { + const out: ScheduleEntry[] = []; + for (const ev of json?.value ?? []) { + if (!ev.activated) continue; + // Date arrives as "YYYY-MM-DD HH:MM:SS" — take the YYYY-MM-DD prefix. + const date = ev.currentDate?.split(' ')[0]; + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue; + if (ev.startTime && ev.endTime) { + out.push({ + date, + type: 'OPERATING' as const, + openingTime: constructDateTime(date, ev.startTime, timezone), + closingTime: constructDateTime(date, ev.endTime, timezone), + }); + } + if (ev.isNight && ev.nightStartTime && ev.nightEndTime) { + out.push({ + date, + type: 'EXTRA_HOURS' as const, + openingTime: constructDateTime(date, ev.nightStartTime, timezone), + closingTime: constructDateTime(date, ev.nightEndTime, timezone), + }); + } + } + return out; +} + +// ── Base class ────────────────────────────────────────────────────────────── + +class Fantawild extends Destination { + /** Static-asset CDN root, e.g. `https://image.fangte.com` (no trailing slash). */ + @config baseUrl: string = ''; + /** Authenticated API root, e.g. `https://leyou.fangte.com` (no trailing slash). */ + @config apiBaseUrl: string = ''; + /** Top-level destination id, e.g. `fantawild_wuhudreamland` */ + @config destinationId: string = ''; + /** Display name for the DESTINATION entity */ + @config destinationName: string = ''; + /** The small numeric parkId Fantawild assigns (17, 19, 21, …) — keyed in BusinessTime URL. */ + @config parkId: number = 0; + /** IANA timezone for the destination. Defaults to Asia/Shanghai (mainland China). */ + @config timezone: string = 'Asia/Shanghai'; + + /** Destination-level geographic location (lat/lng). */ + destinationLocation?: {latitude: number; longitude: number}; + + /** + * Route-prefix constants for the static CDN paths. These are baked into + * the app and only change when Fantawild ships new versioned route maps. + * Override per-subclass if a future app rev splits parks across cohorts. + */ + protected businessTimeVersion: string = '50418'; + protected announcementVersion: string = '50418'; + protected cityParkVersion: string = '50622'; + + constructor(options?: DestinationConstructor) { + super(options); + this.addConfigPrefix('FANTAWILD'); + const cfg = (options?.config ?? {}) as Partial; + if (cfg.destinationLocation) this.destinationLocation = cfg.destinationLocation; + } + + /** Cache-key prefix so per-park caches don't collide on shared method keys. */ + getCacheKeyPrefix(): string { + return `fantawild:${this.parkId}`; + } + + protected async _init(): Promise { + if (!this.baseUrl) { + throw new Error( + `${this.constructor.name} requires baseUrl to be configured ` + + `(set FANTAWILD_BASEURL in .env, e.g. https://image.fangte.com)`, + ); + } + if (!this.apiBaseUrl) { + throw new Error( + `${this.constructor.name} requires apiBaseUrl to be configured ` + + `(set FANTAWILD_APIBASEURL in .env, e.g. https://leyou.fangte.com)`, + ); + } + if (!this.parkId) { + throw new Error(`${this.constructor.name} requires a numeric parkId to be configured`); + } + if (!this.destinationId) { + throw new Error(`${this.constructor.name} requires destinationId to be configured`); + } + } + + // ===== HTTP ===== + + /** Master park list (all 50 Fantawild parks across all cities). Shared across destinations; cache aggressively. */ + @http({cacheSeconds: 60 * 60 * 6, retries: 2}) + async fetchCityPark(): Promise { + return { + method: 'GET', + url: `${this.baseUrl}/UploadFiles/Launch/CityPark/${this.cityParkVersion}.json`, + options: {json: true}, + } as unknown as HTTPObj; + } + + /** Per-park daily opening hours. */ + @http({cacheSeconds: 60 * 15, retries: 2}) + async fetchBusinessTime(): Promise { + return { + method: 'GET', + url: `${this.baseUrl}/UploadFiles/Launch/BusinessTime/${this.businessTimeVersion}/${this.parkId}.json`, + options: {json: true}, + } as unknown as HTTPObj; + } + + /** Per-park announcements. Phase 1 doesn't surface them, but kept for future use. */ + @http({cacheSeconds: 60 * 30, retries: 2}) + async fetchAnnouncements(): Promise { + return { + method: 'GET', + url: `${this.baseUrl}/UploadFiles/Launch/Announcement/${this.announcementVersion}/${this.parkId}.json`, + options: {json: true}, + } as unknown as HTTPObj; + } + + /** + * Per-park ride + show list with live wait times. This endpoint is on the + * separate authenticated host `leyou.fangte.com` rather than the CDN; the + * `selectedDate` query param is required by the server (controls which + * day's show times are reported) but the auth headers we observed in the + * mobile app are NOT enforced — anonymous GETs return the same payload. + * + * Cache 60s — short enough for live-wait freshness, long enough to throttle. + */ + @http({cacheSeconds: 60, retries: 2}) + async fetchItemBusinessList(): Promise { + // Wall-clock in the park's local timezone, formatted the way the app does: + // `YYYY-MM-DD HH:MM:SS.ffffff`. The server reads only the date portion in + // practice, but mirroring the app's format keeps us in the same code path + // on the server side. + const now = formatInTimezone(new Date(), this.timezone, 'iso').slice(0, 19).replace('T', ' '); + const selectedDate = `${now}.000000`; + const params = new URLSearchParams({ + sortType: '1', + SuitablePeopleTag: '', + ItemCharacteristicTag: '', + ItemProperties: '', + PayProperties: '', + FunctionType: '', + height: '0.0', + parkId: String(this.parkId), + selectedDate, + }); + return { + method: 'GET', + url: `${this.apiBaseUrl}/project/api/ParkItem/GetItemBusinessList?${params.toString()}`, + options: {json: true}, + } as unknown as HTTPObj; + } + + // ===== Schedule scraping ===== + + /** + * Fetch + parse the next ~7 days of opening hours. Returns [] on any + * fetch/parse failure so an outage on one park doesn't take out a multi- + * park sweep. + */ + @cache({ttlSeconds: 60 * 10}) + async scrapeSchedule(): Promise { + try { + const resp = await this.fetchBusinessTime(); + const json = await resp.json() as FantawildBusinessTimeResponse; + return parseBusinessTime(json, this.timezone); + } catch { + return []; + } + } + + /** Fetch + return the raw item list. Returns [] on any failure. */ + async fetchItems(): Promise { + try { + const resp = await this.fetchItemBusinessList(); + const json = await resp.json() as FantawildItemListResponse; + return json?.data ?? []; + } catch { + return []; + } + } + + /** Build a stable attraction id from a Fantawild ride id. */ + protected attractionId(itemId: number): string { + return `fantawild_attraction_${this.parkId}_${itemId}`; + } + + // ===== Public-API overrides ===== + + async getDestinations(): Promise { + const dest: Entity = { + id: this.destinationId, + name: this.destinationName, + entityType: 'DESTINATION', + timezone: this.timezone, + } as Entity; + if (this.destinationLocation) { + (dest as Entity & {location?: {latitude: number; longitude: number}}).location = this.destinationLocation; + } + return [dest]; + } + + protected async buildEntityList(): Promise { + const parkId = `fantawild_park_${this.parkId}`; + const parkEntity: Entity = { + id: parkId, + name: this.destinationName, + entityType: 'PARK', + parentId: this.destinationId, + destinationId: this.destinationId, + timezone: this.timezone, + } as Entity; + if (this.destinationLocation) { + (parkEntity as Entity & {location?: {latitude: number; longitude: number}}).location = this.destinationLocation; + } + + const out: Entity[] = [parkEntity]; + const items = await this.fetchItems(); + for (const item of items) { + // Skip entries with no usable id or name. + if (!item.id) continue; + const cleanName = stripFantawildStars(item.itemName || ''); + if (!cleanName) continue; + const entityType = isFantawildShow(item) ? 'SHOW' : 'ATTRACTION'; + const entity: Entity = { + id: this.attractionId(item.id), + name: cleanName, + entityType, + parentId: parkId, + parkId, + destinationId: this.destinationId, + timezone: this.timezone, + } as Entity; + if (Number.isFinite(item.latitude) && Number.isFinite(item.longitude)) { + (entity as Entity & {location?: {latitude: number; longitude: number}}).location = { + latitude: item.latitude!, + longitude: item.longitude!, + }; + } + out.push(entity); + } + return out; + } + + protected async buildLiveData(): Promise { + const items = await this.fetchItems(); + const out: LiveData[] = []; + for (const item of items) { + if (!item.id) continue; + const isOpen = item.itemOpened === true; + // `项目维护` ("under maintenance") explicitly flags a planned closure + // distinct from "closed because the park's closed" — surface as REFURBISHMENT. + const isMaintenance = !!item.statusStr && /维护/.test(item.statusStr); + const status = isMaintenance ? 'REFURBISHMENT' : (isOpen ? 'OPERATING' : 'CLOSED'); + const ld: LiveData = { + id: this.attractionId(item.id), + status, + } as LiveData; + // Only attach a STANDBY wait time when the ride is OPERATING and the + // API returned a finite, non-negative value. Avoid emitting waitTime=0 + // for closed rides — `0` should mean "no queue right now," not "park + // closed so we don't actually know." + if (status === 'OPERATING' && Number.isFinite(item.waitTime) && item.waitTime >= 0) { + (ld as LiveData & {queue?: Record}).queue = { + STANDBY: {waitTime: item.waitTime}, + }; + } + out.push(ld); + } + return out; + } + + protected async buildSchedules(): Promise { + const schedule = await this.scrapeSchedule(); + return [{id: `fantawild_park_${this.parkId}`, schedule} as EntitySchedule]; + } +} + +export {Fantawild}; diff --git a/src/parks/fantawild/wuhudreamland.ts b/src/parks/fantawild/wuhudreamland.ts new file mode 100644 index 00000000..3f061ff9 --- /dev/null +++ b/src/parks/fantawild/wuhudreamland.ts @@ -0,0 +1,20 @@ +import {Fantawild} from './fantawild.js'; +import {destinationController} from '../../destinationRegistry.js'; +import type {DestinationConstructor} from '../../destination.js'; + +@destinationController({category: ['Fantawild']}) +export class WuhuDreamland extends Fantawild { + constructor(options?: DestinationConstructor) { + super({ + ...options, + config: { + destinationId: 'fantawild_wuhudreamland', + destinationName: 'Fantawild Dreamland Wuhu', + parkId: 19, + timezone: 'Asia/Shanghai', + ...(options?.config ?? {}), + }, + }); + this.destinationLocation ??= {latitude: 31.3599, longitude: 118.4582}; + } +} From 38128f0a90e40937dc366fdbc6a909209dd05da4 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 05:24:10 +0000 Subject: [PATCH 2/9] fix(fantawild): TS build error + dedup cache + refresh header comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review issues from PR #227: - wuhudreamland: numeric parkId can't go through DestinationConstructor.config (it's `{[k]: string | string[]}`). Assign directly after super() with env-var override preserved via the @config decorator. - fantawild base: add @cache to fetchItems() so the entity build and the live-data build share one payload per parkId per 60s window — the underlying @http cache keys differ because selectedDate bakes in the current wall-clock. - fantawild base: rewrite the header comment — it described a not-yet-built Phase 2 even though both phases now ship in this file. Co-Authored-By: Claude Opus 4.7 --- src/parks/fantawild/fantawild.ts | 44 +++++++++++++++++----------- src/parks/fantawild/wuhudreamland.ts | 6 +++- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index 3a688183..e64a1fb3 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -4,25 +4,25 @@ * The Fantawild chain operates ~50 parks across China under several brand * lines (Dreamland 梦幻王国, Oriental Heritage 东方神画, Adventure 欢乐世界, * Boonie Bears 熊出没, Water Park 水上乐园, etc.). All parks share a common - * mobile app (`方特旅游`, package `com.hytch.ftthemepark`) which fetches - * static metadata from a shared CDN at `image.fangte.com`. + * mobile app (`方特旅游`, package `com.hytch.ftthemepark`). * - * Phase 1 (this file): destination + park entity + operating-hours schedule, - * sourced entirely from the unauthenticated CDN. No per-ride data. + * Two backends, both anonymous-callable in practice (the app sends bearer + + * HMAC headers, but neither is enforced server-side): * - * Phase 2 (future): ride list + live wait times require a bearer token from - * `leyou.fangte.com`; the REST path is not recoverable statically because the - * Dart binary (Flutter) didn't yield to blutter. Needs a runtime device - * capture to discover the endpoint. + * - Static CDN: `image.fangte.com` + * /UploadFiles/Launch/CityPark/{cityParkVersion}.json + * Master park list. {version} is a release pointer, not a parkId. + * /UploadFiles/Launch/BusinessTime/{businessTimeVersion}/{parkId}.json + * Per-park daily opening hours, keyed by the small parkId. + * /UploadFiles/Launch/Announcement/{businessTimeVersion}/{parkId}.json + * Per-park announcements. * - * CDN routes used (all GET, no auth): - * /UploadFiles/Launch/CityPark/{cityParkVersion}.json - * - Master park list. The numeric `{version}` is a release pointer, not - * a parkId. Same content is served from multiple version numbers. - * /UploadFiles/Launch/BusinessTime/{businessTimeVersion}/{parkId}.json - * - Per-park daily opening hours, keyed by the small parkId (17, 19, …). - * /UploadFiles/Launch/Announcement/{businessTimeVersion}/{parkId}.json - * - Per-park announcements (rich-text URLs). + * - JSON API: `leyou.fangte.com` + * /project/api/ParkItem/GetItemBusinessList?parkId=…&selectedDate=… + * Full ride + show list with live `waitTime`, `itemOpened` flag, + * `statusStr` (e.g. `项目维护` = under maintenance), per-ride + * lat/lng, and `showTimeList` of operating-hours range or discrete + * show times. */ import {Destination, type DestinationConstructor} from '../../destination.js'; @@ -326,7 +326,17 @@ class Fantawild extends Destination { } } - /** Fetch + return the raw item list. Returns [] on any failure. */ + /** + * Fetch + return the raw item list. Returns [] on any failure. + * + * Cached 60s. `fetchItemBusinessList()` bakes the wall-clock into the + * `selectedDate` query string, so its underlying `@http` cache key + * changes every second and wouldn't dedupe the calls fired by + * `buildEntityList()` and `buildLiveData()` in the same tick. The + * argless `@cache` here keys on class + method only, so both builders + * share one payload per parkId per 60s window. + */ + @cache({ttlSeconds: 60}) async fetchItems(): Promise { try { const resp = await this.fetchItemBusinessList(); diff --git a/src/parks/fantawild/wuhudreamland.ts b/src/parks/fantawild/wuhudreamland.ts index 3f061ff9..b1c2451a 100644 --- a/src/parks/fantawild/wuhudreamland.ts +++ b/src/parks/fantawild/wuhudreamland.ts @@ -10,11 +10,15 @@ export class WuhuDreamland extends Fantawild { config: { destinationId: 'fantawild_wuhudreamland', destinationName: 'Fantawild Dreamland Wuhu', - parkId: 19, timezone: 'Asia/Shanghai', ...(options?.config ?? {}), }, }); + // parkId is numeric and DestinationConstructor.config is string-only, so + // assign it directly. The @config decorator still honours an env-var + // override (FANTAWILD_PARKID / WUHUDREAMLAND_PARKID); only fall back to + // the literal when nothing set it. + if (!this.parkId) this.parkId = 19; this.destinationLocation ??= {latitude: 31.3599, longitude: 118.4582}; } } From 5f03dd50eb44edef4f7f871d9fccfb086ae2de02 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 06:04:43 +0000 Subject: [PATCH 3/9] fix(fantawild): cross-midnight schedule roll + longer schedule cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of three Copilot comments on PR #227: - parseBusinessTime now rolls the close date forward one day when closingTime <= openingTime (e.g. 18:00–00:30, 22:00–01:00). Detection uses the wall-clock values directly rather than trusting the upstream `isMorrow` flag, whose exact semantics aren't pinned down by a real fixture (every observed entry is `isMorrow: false`). Mirrors the Europa-Park Sommernächte fix from #224. Applies to both OPERATING and EXTRA_HOURS (night-event) windows. Tests added for the cross-midnight case, the month-boundary case, and the strict no-roll case. - scrapeSchedule @cache TTL bumped 10min → 6h. BusinessTime is a forward-looking calendar that rarely changes intra-day; the 10-minute TTL was wasted parse work and excess CDN traffic across a 50-park sweep. fetchBusinessTime's own @http cache still gives a 15-min upstream-update floor. (The third comment — _init throwing on missing baseUrl/apiBaseUrl — follows the EnchantedParks pattern of failing fast with a helpful error message rather than letting requests die mid-fetch later.) Co-Authored-By: Claude Opus 4.7 --- .../fantawild/__tests__/fantawild.test.ts | 69 +++++++++++++++++++ src/parks/fantawild/fantawild.ts | 53 ++++++++++++-- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts index e2a2afb0..7b340052 100644 --- a/src/parks/fantawild/__tests__/fantawild.test.ts +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -127,6 +127,75 @@ describe('parseBusinessTime', () => { expect(parseBusinessTime({key: 'k', value: []}, TZ)).toEqual([]); }); + test('rolls closing time onto the next day when it crosses midnight', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-21 00:00:00', + startTime: '18:00', endTime: '00:30', + isNight: false, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(1); + expect(out[0].openingTime).toBe('2026-06-21T18:00:00+08:00'); + // close should be 2026-06-22, NOT 2026-06-21 (which would be before opening). + expect(out[0].closingTime).toBe('2026-06-22T00:30:00+08:00'); + }); + + test('rolls EXTRA_HOURS closing onto the next day when night event crosses midnight', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-21 00:00:00', + startTime: '09:30', endTime: '21:00', + isNight: true, isMorrow: false, + nightStartTime: '22:00', nightEndTime: '01:00', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(2); + expect(out[0].type).toBe('OPERATING'); + expect(out[0].closingTime).toBe('2026-06-21T21:00:00+08:00'); + expect(out[1].type).toBe('EXTRA_HOURS'); + expect(out[1].openingTime).toBe('2026-06-21T22:00:00+08:00'); + expect(out[1].closingTime).toBe('2026-06-22T01:00:00+08:00'); + }); + + test('rolls past month boundary correctly', () => { + // 2026-06-30 → 2026-07-01 (month rollover). + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-30 00:00:00', + startTime: '20:00', endTime: '02:00', + isNight: false, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out[0].closingTime).toBe('2026-07-01T02:00:00+08:00'); + }); + + test('does NOT roll when closing time is strictly after opening', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-21 00:00:00', + startTime: '09:30', endTime: '23:59', + isNight: false, isMorrow: false, + nightStartTime: '', nightEndTime: '', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out[0].closingTime).toBe('2026-06-21T23:59:00+08:00'); + }); + test('processes a multi-day fixture in the order the API returns it', () => { // The API doesn't sort by date — entries arrive in app-storage order. // The parser should NOT re-sort; the destination's schedule renderer diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index e64a1fb3..df1bd942 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -42,6 +42,13 @@ export interface FantawildBusinessTimeEntry { /** "HH:MM" — empty string when closed */ endTime: string; isNight: boolean; + /** + * Field name suggests "is the next day," but its exact semantics have not + * been confirmed against a real cross-midnight fixture — every observed + * entry has `isMorrow: false`. We detect midnight-crossing closing times + * directly from the wall-clock values (close < open → roll) rather than + * trusting this flag, which is strictly safer either way. + */ isMorrow: boolean; nightStartTime: string; nightEndTime: string; @@ -140,6 +147,33 @@ export function isFantawildShow(item: FantawildItem): boolean { // ── Schedule parser ───────────────────────────────────────────────────────── +/** Parse "HH:MM" to minutes-from-midnight. Returns NaN if malformed. */ +function hhmmToMinutes(t: string): number { + const m = /^(\d{1,2}):(\d{2})$/.exec(t); + if (!m) return NaN; + return Number(m[1]) * 60 + Number(m[2]); +} + +/** + * If a window's closing time is at or before its opening time, the window + * crosses midnight — return tomorrow's date for use as the close date. Else + * return the same date. Times are wall-clock "HH:MM" in the park's timezone. + * + * Mirrors the post-midnight fix Europa-Park needed for Sommernächte (PR #224). + * We don't trust the upstream `isMorrow` flag — its semantics aren't pinned + * down by a real fixture — but wall-clock ordering is unambiguous. + */ +function closeDateAcrossMidnight(date: string, openTime: string, closeTime: string): string { + const opens = hhmmToMinutes(openTime); + const closes = hhmmToMinutes(closeTime); + if (!Number.isFinite(opens) || !Number.isFinite(closes)) return date; + if (closes > opens) return date; + // YYYY-MM-DD → next day. Date.UTC handles month/year rollover correctly. + const [y, m, d] = date.split('-').map(Number); + const next = new Date(Date.UTC(y, (m - 1), d + 1)); + return next.toISOString().slice(0, 10); +} + /** * Convert a Fantawild BusinessTime response to ScheduleEntry[]. * @@ -148,7 +182,8 @@ export function isFantawildShow(item: FantawildItem): boolean { * or whose date can't be parsed. Adds an EXTRA_HOURS entry when `isNight` * is true and night times are populated — the park's day session has its * own startTime/endTime, and the night session (e.g. fireworks/dark-ride - * event) is layered on top. + * event) is layered on top. Closing times at or before opening (e.g. + * 18:00–00:30 or 22:00–01:00) roll the close date to the next day. */ export function parseBusinessTime( json: FantawildBusinessTimeResponse | null | undefined, @@ -161,19 +196,21 @@ export function parseBusinessTime( const date = ev.currentDate?.split(' ')[0]; if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue; if (ev.startTime && ev.endTime) { + const closeDate = closeDateAcrossMidnight(date, ev.startTime, ev.endTime); out.push({ date, type: 'OPERATING' as const, openingTime: constructDateTime(date, ev.startTime, timezone), - closingTime: constructDateTime(date, ev.endTime, timezone), + closingTime: constructDateTime(closeDate, ev.endTime, timezone), }); } if (ev.isNight && ev.nightStartTime && ev.nightEndTime) { + const nightCloseDate = closeDateAcrossMidnight(date, ev.nightStartTime, ev.nightEndTime); out.push({ date, type: 'EXTRA_HOURS' as const, openingTime: constructDateTime(date, ev.nightStartTime, timezone), - closingTime: constructDateTime(date, ev.nightEndTime, timezone), + closingTime: constructDateTime(nightCloseDate, ev.nightEndTime, timezone), }); } } @@ -311,11 +348,17 @@ class Fantawild extends Destination { // ===== Schedule scraping ===== /** - * Fetch + parse the next ~7 days of opening hours. Returns [] on any + * Fetch + parse the next ~7-10 days of opening hours. Returns [] on any * fetch/parse failure so an outage on one park doesn't take out a multi- * park sweep. + * + * Cached 6h — BusinessTime is a forward-looking calendar that almost + * never changes intra-day, so the short TTL was wasted parse work and + * extra CDN traffic across a 50-park sweep. `fetchBusinessTime()`'s own + * `@http` cache still gives a 15-min upstream-update floor for the rare + * case (closure pushed at short notice). */ - @cache({ttlSeconds: 60 * 10}) + @cache({ttlSeconds: 60 * 60 * 6}) async scrapeSchedule(): Promise { try { const resp = await this.fetchBusinessTime(); From bfc9945ef8ad5873ce0166a62a61c10d8f596ccc Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 06:13:12 +0000 Subject: [PATCH 4/9] docs(fantawild): error messages name both CLASS- and FANTAWILD-prefixed env vars Per the @config resolution order ({CLASSNAME}_{PROPERTY} > {PREFIX}_{PROPERTY}), a user configuring a single park can set WUHUDREAMLAND_BASEURL without ever needing the shared FANTAWILD_BASEURL. The error pointed only at the shared prefix, which is misleading when only one subclass needs override. Co-Authored-By: Claude Opus 4.7 --- src/parks/fantawild/fantawild.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index df1bd942..53361775 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -258,16 +258,17 @@ class Fantawild extends Destination { } protected async _init(): Promise { + const cls = this.constructor.name.toUpperCase(); if (!this.baseUrl) { throw new Error( `${this.constructor.name} requires baseUrl to be configured ` + - `(set FANTAWILD_BASEURL in .env, e.g. https://image.fangte.com)`, + `(set ${cls}_BASEURL or FANTAWILD_BASEURL in .env, e.g. https://image.fangte.com)`, ); } if (!this.apiBaseUrl) { throw new Error( `${this.constructor.name} requires apiBaseUrl to be configured ` + - `(set FANTAWILD_APIBASEURL in .env, e.g. https://leyou.fangte.com)`, + `(set ${cls}_APIBASEURL or FANTAWILD_APIBASEURL in .env, e.g. https://leyou.fangte.com)`, ); } if (!this.parkId) { From 9bee65f81196d6153e5356dab2f38dde60821b5d Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 06:25:58 +0000 Subject: [PATCH 5/9] fix(fantawild): guard time parsing + short-cache empty schedules Both Copilot comments on commit bfc9945e: - parseBusinessTime validates time strings via isValidHHMM() before feeding them to constructDateTime. A malformed entry now drops just that entry instead of throwing and aborting the whole sweep. hhmmToMinutes also rejects out-of-range hours/minutes (>24h, >59m) so '25:00' is treated as malformed rather than producing nonsense. - scrapeSchedule @cache uses a dynamic TTL: 6h for a populated result, 60s for an empty one. Stops a transient CDN/parse failure from freezing a fabricated zero-day schedule for hours. Mirrors the 'cache only TRUE, never FALSE' pattern already established for derived boolean flags. Co-Authored-By: Claude Opus 4.7 --- .../fantawild/__tests__/fantawild.test.ts | 35 +++++++++++++++++++ src/parks/fantawild/fantawild.ts | 28 ++++++++++----- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts index 7b340052..c98e8183 100644 --- a/src/parks/fantawild/__tests__/fantawild.test.ts +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -181,6 +181,41 @@ describe('parseBusinessTime', () => { expect(out[0].closingTime).toBe('2026-07-01T02:00:00+08:00'); }); + test('skips entries with malformed startTime/endTime instead of throwing', () => { + // A garbage time string would otherwise blow up constructDateTime and abort + // the entire sweep. Make sure only the bad entry is dropped, the good one + // is still parsed. + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [ + // Bad: non-HH:MM startTime + {currentDate: '2026-06-21 00:00:00', startTime: 'morning', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + // Bad: out-of-range hour + {currentDate: '2026-06-22 00:00:00', startTime: '25:00', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + // Good: should still parse + {currentDate: '2026-06-23 00:00:00', startTime: '09:30', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + ], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(1); + expect(out[0].date).toBe('2026-06-23'); + }); + + test('skips night event with malformed nightStartTime but keeps the OPERATING entry', () => { + const json: FantawildBusinessTimeResponse = { + key: 'k', value: [{ + currentDate: '2026-06-21 00:00:00', + startTime: '09:30', endTime: '21:00', + isNight: true, isMorrow: false, + nightStartTime: 'evening', nightEndTime: '23:00', + activated: true, statusTips: '', + parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '', + }], + }; + const out = parseBusinessTime(json, TZ); + expect(out).toHaveLength(1); + expect(out[0].type).toBe('OPERATING'); + }); + test('does NOT roll when closing time is strictly after opening', () => { const json: FantawildBusinessTimeResponse = { key: 'k', value: [{ diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index 53361775..8ee5f1b6 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -151,7 +151,14 @@ export function isFantawildShow(item: FantawildItem): boolean { function hhmmToMinutes(t: string): number { const m = /^(\d{1,2}):(\d{2})$/.exec(t); if (!m) return NaN; - return Number(m[1]) * 60 + Number(m[2]); + const h = Number(m[1]); const min = Number(m[2]); + if (h > 24 || min > 59) return NaN; + return h * 60 + min; +} + +/** Time string is parseable as "HH:MM". Avoids feeding garbage to constructDateTime. */ +function isValidHHMM(t: string): boolean { + return Number.isFinite(hhmmToMinutes(t)); } /** @@ -195,7 +202,9 @@ export function parseBusinessTime( // Date arrives as "YYYY-MM-DD HH:MM:SS" — take the YYYY-MM-DD prefix. const date = ev.currentDate?.split(' ')[0]; if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue; - if (ev.startTime && ev.endTime) { + // Validate time strings BEFORE handing them to constructDateTime — a single + // malformed entry would otherwise throw and abort the whole sweep. + if (ev.startTime && ev.endTime && isValidHHMM(ev.startTime) && isValidHHMM(ev.endTime)) { const closeDate = closeDateAcrossMidnight(date, ev.startTime, ev.endTime); out.push({ date, @@ -204,7 +213,8 @@ export function parseBusinessTime( closingTime: constructDateTime(closeDate, ev.endTime, timezone), }); } - if (ev.isNight && ev.nightStartTime && ev.nightEndTime) { + if (ev.isNight && ev.nightStartTime && ev.nightEndTime + && isValidHHMM(ev.nightStartTime) && isValidHHMM(ev.nightEndTime)) { const nightCloseDate = closeDateAcrossMidnight(date, ev.nightStartTime, ev.nightEndTime); out.push({ date, @@ -353,13 +363,13 @@ class Fantawild extends Destination { * fetch/parse failure so an outage on one park doesn't take out a multi- * park sweep. * - * Cached 6h — BusinessTime is a forward-looking calendar that almost - * never changes intra-day, so the short TTL was wasted parse work and - * extra CDN traffic across a 50-park sweep. `fetchBusinessTime()`'s own - * `@http` cache still gives a 15-min upstream-update floor for the rare - * case (closure pushed at short notice). + * Dynamic cache TTL: 6h for a populated result (BusinessTime is a + * forward-looking calendar that rarely changes intra-day, so a long TTL + * cuts wasted parse work and CDN traffic across a 50-park sweep); + * 60s if the result is empty — a transient CDN/parse failure shouldn't + * stick around as a fabricated zero-day schedule for hours. */ - @cache({ttlSeconds: 60 * 60 * 6}) + @cache({callback: (result: ScheduleEntry[]) => result.length === 0 ? 60 : 60 * 60 * 6}) async scrapeSchedule(): Promise { try { const resp = await this.fetchBusinessTime(); From 4580e060cf8f90f06168f3146d48f9b0b5fc8f93 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 06:39:25 +0000 Subject: [PATCH 6/9] fix(fantawild): hhmmToMinutes tightens hour range to 0-23 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One of two Copilot comments on commit 9bee65f8: hhmmToMinutes checked `h > 24` which let '24:30' slip through, after which constructDateTime would produce a NaN Date and downstream garbage. The 24:00 carve-out has no real value for Fantawild's API — treat hours as 0-23 strictly. Test fixture now also covers '24:30' and the out-of-range minute case. (The other comment — `config` import being unused — is incorrect; it's the @config decorator used on baseUrl/apiBaseUrl/destinationId/ destinationName/parkId/timezone.) Co-Authored-By: Claude Opus 4.7 --- src/parks/fantawild/__tests__/fantawild.test.ts | 11 ++++++++--- src/parks/fantawild/fantawild.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts index c98e8183..5b60503c 100644 --- a/src/parks/fantawild/__tests__/fantawild.test.ts +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -189,15 +189,20 @@ describe('parseBusinessTime', () => { key: 'k', value: [ // Bad: non-HH:MM startTime {currentDate: '2026-06-21 00:00:00', startTime: 'morning', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, - // Bad: out-of-range hour + // Bad: out-of-range hour (25) {currentDate: '2026-06-22 00:00:00', startTime: '25:00', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + // Bad: 24:30 — 24 is not a valid wall-clock hour; the carve-out for 24:00 is + // out of scope for Fantawild's API and would just complicate constructDateTime. + {currentDate: '2026-06-23 00:00:00', startTime: '09:00', endTime: '24:30', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + // Bad: out-of-range minute + {currentDate: '2026-06-24 00:00:00', startTime: '09:60', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, // Good: should still parse - {currentDate: '2026-06-23 00:00:00', startTime: '09:30', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, + {currentDate: '2026-06-25 00:00:00', startTime: '09:30', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''}, ], }; const out = parseBusinessTime(json, TZ); expect(out).toHaveLength(1); - expect(out[0].date).toBe('2026-06-23'); + expect(out[0].date).toBe('2026-06-25'); }); test('skips night event with malformed nightStartTime but keeps the OPERATING entry', () => { diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index 8ee5f1b6..4adbdf18 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -147,12 +147,18 @@ export function isFantawildShow(item: FantawildItem): boolean { // ── Schedule parser ───────────────────────────────────────────────────────── -/** Parse "HH:MM" to minutes-from-midnight. Returns NaN if malformed. */ +/** + * Parse "HH:MM" to minutes-from-midnight. Returns NaN if malformed. + * + * Strict hour range 0-23 — `24:30` would slip past a `>24` check, then + * `constructDateTime` would emit NaN-of-Date and produce garbage. Use + * `00:00` next-day instead if you need the end-of-day boundary. + */ function hhmmToMinutes(t: string): number { const m = /^(\d{1,2}):(\d{2})$/.exec(t); if (!m) return NaN; const h = Number(m[1]); const min = Number(m[2]); - if (h > 24 || min > 59) return NaN; + if (h > 23 || min > 59) return NaN; return h * 60 + min; } From 003e27abde804a89016457809264a0b8e1713fef Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 08:43:43 +0000 Subject: [PATCH 7/9] refactor(fantawild): uber-class with hardcoded park array (SixFlags pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure from "abstract base + 1 subclass per park" to a single registered class that loops a curated FANTAWILD_PARKS array, matching how SixFlags handles its chain. Ships 49 Fantawild parks across mainland China in one go (vs the single Wuhu Dreamland subclass before). Key design points: - DESTINATION/PARK/ATTRACTION IDs derive from numeric parkId (`fantawild_destination_19`, `fantawild_park_19`, `fantawild_attraction_19_`) — stable across data refreshes. - `hasLiveWaitTimes` is a per-park static flag set from a single live-API probe (2026-06-21 ~15:00 China time), OR'd at runtime with a permissive write-once observation cache: any park that ever returns waitTime > 0 in production gets marked live forever (90d cache TTL, never written FALSE). New parks rolling out live waits self-correct. - Schedule cross-check on every live-data tick: `itemOpened: true` alone doesn't mean the gate is open right now (verified at 5 AM CT the API still reports every ride OPERATING). buildLiveData consults the BusinessTime schedule and forces CLOSED when outside today's operating window. - `selectedDate` query param rounded to the minute so the @http cache key on fetchItemBusinessList stays stable for 60s instead of shifting every second. - `getItems` + `getSchedule` carry `cacheVersion: 1` so future shape changes invalidate stale cache entries automatically. - `getDestinations()` calls `init()` up front so an unconfigured deploy fails the same way the live-data poll would, instead of registering 49 ghost destinations. - `buildEntityList`, `buildLiveData`, `buildSchedules` all wrapped in `@reusable()` to coalesce collector poll-bursts. - Drop dead `fetchAnnouncements` + scaffolding `cityParkVersion` — YAGNI; add back when something needs them. Tests: 1263 pass. End-to-end: 49 destinations, 1564 entities, 1466 live records (337 with live waits), 3611 days of schedule. Co-Authored-By: Claude Opus 4.7 --- src/__tests__/entityIdRegression.test.ts | 55 +- .../fantawild/__tests__/fantawild.test.ts | 12 + src/parks/fantawild/fantawild.ts | 553 +++++++++++------- src/parks/fantawild/wuhudreamland.ts | 24 - 4 files changed, 411 insertions(+), 233 deletions(-) delete mode 100644 src/parks/fantawild/wuhudreamland.ts diff --git a/src/__tests__/entityIdRegression.test.ts b/src/__tests__/entityIdRegression.test.ts index 5299f7c3..e690da07 100644 --- a/src/__tests__/entityIdRegression.test.ts +++ b/src/__tests__/entityIdRegression.test.ts @@ -233,19 +233,56 @@ describe('Destination ID patterns', () => { expect(destinations[0]?.id).not.toBe('galvestonislandwaterpark'); }); - test('Wuhu Dreamland (Fantawild) is registered under the Fantawild umbrella', async () => { + test('Fantawild uber-class is registered under the Fantawild category', async () => { const destinations = await getAllDestinations(); - const ids = destinations.map(d => d.id); - expect(ids).toContain('wuhudreamland'); - const wd = destinations.find(d => d.id === 'wuhudreamland'); - expect(wd?.category).toEqual(['Fantawild']); + const fantawild = destinations.find(d => d.id === 'fantawild'); + expect(fantawild).toBeDefined(); + expect(fantawild?.category).toBe('Fantawild'); }); - test('Wuhu Dreamland emits fantawild-namespaced DESTINATION entity id', async () => { - const {WuhuDreamland} = await import('../parks/fantawild/wuhudreamland.js'); - const dest = new WuhuDreamland({}); + test('Fantawild emits one DESTINATION entity per park in FANTAWILD_PARKS', async () => { + const {Fantawild, FANTAWILD_PARKS} = await import('../parks/fantawild/fantawild.js'); + const dest = new Fantawild({config: { + baseUrl: 'https://image.fangte.com', + apiBaseUrl: 'https://leyou.fangte.com', + }}); const destinations = await dest.getDestinations(); - expect(destinations[0]?.id).toBe('fantawild_wuhudreamland'); + expect(destinations.length).toBe(FANTAWILD_PARKS.length); + // All ids follow the `fantawild_destination_` scheme + for (const e of destinations) { + expect(e.id).toMatch(/^fantawild_destination_\d+$/); + expect(e.entityType).toBe('DESTINATION'); + } + // Spot-check a known park (Wuhu Dreamland, parkId 19) + const wuhu = destinations.find(d => d.id === 'fantawild_destination_19'); + expect(wuhu).toBeDefined(); + expect(wuhu?.name).toBe('Fantawild Dreamland Wuhu'); + }); + + test('Fantawild destination IDs are unique across the park list', async () => { + const {FANTAWILD_PARKS} = await import('../parks/fantawild/fantawild.js'); + const ids = FANTAWILD_PARKS.map(p => p.parkId); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('Fantawild getDestinations and buildEntityList emit the SAME destination IDs', async () => { + // Drift between these two surfaces silently corrupts the wiki: the + // registry would advertise one set of destination IDs while the entity + // sweep emits another. Lock them together. + const {Fantawild} = await import('../parks/fantawild/fantawild.js'); + const dest = new Fantawild({config: { + baseUrl: 'https://image.fangte.com', + apiBaseUrl: 'https://leyou.fangte.com', + }}); + // Stub the live HTTP layer so buildEntityList doesn't network during the test. + (dest as unknown as {getItems: () => Promise}).getItems = async () => []; + const [destinations, entities] = await Promise.all([ + dest.getDestinations(), + dest.getEntities(), + ]); + const destIds = new Set(destinations.map(d => d.id)); + const entityDestIds = new Set(entities.filter(e => e.entityType === 'DESTINATION').map(e => e.id)); + expect(entityDestIds).toEqual(destIds); }); test('Attractions.io v1 Merlin parks are registered individually', async () => { diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts index 5b60503c..7703d565 100644 --- a/src/parks/fantawild/__tests__/fantawild.test.ts +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -307,6 +307,18 @@ describe('isFantawildShow', () => { expect(isFantawildShow(baseItem({showTimeList: [], featureList: ['亲子']}))).toBe(false); }); + test('parade feature with empty showTimeList still flags as SHOW', () => { + // Some parades have no fixed times listed but should still classify as SHOW. + expect(isFantawildShow(baseItem({showTimeList: [], featureList: ['巡游']}))).toBe(true); + }); + + test('parade feature with discrete showtimes flags as SHOW', () => { + expect(isFantawildShow(baseItem({ + showTimeList: ['15:00', '16:30'], + featureList: ['巡游', '亲子'], + }))).toBe(true); + }); + test('handles mixed list (range + discrete) by treating it as RIDE', () => { // If ANY entry is a range, lean toward attraction hours rather than show times. expect(isFantawildShow(baseItem({ diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index 4adbdf18..02f93e98 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -1,5 +1,7 @@ /** - * Fantawild (方特 / Fang Te) — shared base class for all Fantawild theme parks. + * Fantawild (方特 / Fang Te) — single registered destination class that + * emits one DESTINATION/PARK pair per real-world Fantawild park, plus + * the chain's attractions and shows. * * The Fantawild chain operates ~50 parks across China under several brand * lines (Dreamland 梦幻王国, Oriental Heritage 东方神画, Adventure 欢乐世界, @@ -10,12 +12,8 @@ * HMAC headers, but neither is enforced server-side): * * - Static CDN: `image.fangte.com` - * /UploadFiles/Launch/CityPark/{cityParkVersion}.json - * Master park list. {version} is a release pointer, not a parkId. * /UploadFiles/Launch/BusinessTime/{businessTimeVersion}/{parkId}.json * Per-park daily opening hours, keyed by the small parkId. - * /UploadFiles/Launch/Announcement/{businessTimeVersion}/{parkId}.json - * Per-park announcements. * * - JSON API: `leyou.fangte.com` * /project/api/ParkItem/GetItemBusinessList?parkId=…&selectedDate=… @@ -23,14 +21,116 @@ * `statusStr` (e.g. `项目维护` = under maintenance), per-ride * lat/lng, and `showTimeList` of operating-hours range or discrete * show times. + * + * Pattern follows SixFlags: one `@destinationController` class that loops + * the FANTAWILD_PARKS array inside getDestinations/buildEntityList/etc. */ import {Destination, type DestinationConstructor} from '../../destination.js'; import {http, type HTTPObj} from '../../http.js'; -import {cache} from '../../cache.js'; +import {cache, CacheLib} from '../../cache.js'; import config from '../../config.js'; +import {destinationController} from '../../destinationRegistry.js'; +import {reusable} from '../../promiseReuse.js'; import type {Entity, LiveData, EntitySchedule, ScheduleEntry} from '@themeparks/typelib'; -import {constructDateTime, formatInTimezone} from '../../datetime.js'; +import {constructDateTime, formatInTimezone, formatDate} from '../../datetime.js'; + +// ── Curated park list ─────────────────────────────────────────────────────── + +/** + * One Fantawild theme park. + * + * `hasLiveWaitTimes` is a per-park flag: when `true`, `buildLiveData` + * surfaces the API's `waitTime` field as a STANDBY queue; when `false`, + * we still emit OPERATING/CLOSED/REFURBISHMENT status but skip waitTime + * entirely. This avoids fabricating `waitTime: 0` for parks that don't + * actually broadcast live queue data — every ride at such a park would + * otherwise report a permanent zero-minute queue. + * + * `hasLiveWaitTimes` was set from a single weekday-afternoon probe of + * the live API (2026-06-21 ~15:00 China time). Parks that returned at + * least one ride with `waitTime > 0` in that probe are marked true. + * Flip new entries to true as evidence appears. + */ +export interface FantawildParkConfig { + /** Numeric Fantawild parkId (from CityPark) — keyed in BusinessTime + API URLs. */ + parkId: number; + /** English display name for the destination. Used for DESTINATION + PARK entities. */ + name: string; + /** IANA timezone (almost always `Asia/Shanghai` for mainland China). */ + timezone: string; + /** Park-level geographic centroid (lat/lng). */ + location: {latitude: number; longitude: number}; + /** Whether the live API broadcasts real waitTime values for this park. */ + hasLiveWaitTimes: boolean; +} + +/** + * Every Fantawild park we ship to TP.wiki. Curated from CityPark master list + * (50 parks across 30 cities, 49 of which return ride data). Excluded: + * `Boonie Cubs 熊熊乐园` Shenzhen (parkId 133) — returns no items, app + * placeholder for an unfinished sub-park. Names are English where Fantawild + * provides one; otherwise translated from the Chinese brand line + city. + * + * Adding a park: append one entry. No new class file needed. + * + * `EXCLUDED_PARK_IDS` are CityPark entries we deliberately drop (currently + * only parkId 133 — `Boonie Cubs 熊熊乐园` Shenzhen, an app placeholder + * with 0 items and a (0,0) location). + */ +export const EXCLUDED_PARK_IDS: ReadonlySet = new Set([133]); + +export const FANTAWILD_PARKS: readonly FantawildParkConfig[] = [ + {parkId: 17, name: "Fantawild Adventure Tai'an", timezone: 'Asia/Shanghai', location: {latitude: 36.2387, longitude: 117.1933}, hasLiveWaitTimes: false}, + {parkId: 19, name: 'Fantawild Dreamland Wuhu', timezone: 'Asia/Shanghai', location: {latitude: 31.3599, longitude: 118.4582}, hasLiveWaitTimes: false}, + {parkId: 21, name: 'Fantawild Dreamland Qingdao', timezone: 'Asia/Shanghai', location: {latitude: 36.2103, longitude: 120.2820}, hasLiveWaitTimes: true}, + {parkId: 23, name: 'Fantawild Adventure Zhuzhou', timezone: 'Asia/Shanghai', location: {latitude: 27.9900, longitude: 113.1932}, hasLiveWaitTimes: false}, + {parkId: 25, name: 'Fantawild Adventure Shenyang', timezone: 'Asia/Shanghai', location: {latitude: 41.9648, longitude: 123.4195}, hasLiveWaitTimes: true}, + {parkId: 27, name: 'Fantawild Adventure Zhengzhou', timezone: 'Asia/Shanghai', location: {latitude: 34.7666, longitude: 113.9324}, hasLiveWaitTimes: false}, + {parkId: 31, name: 'Fantawild Dreamland Xiamen', timezone: 'Asia/Shanghai', location: {latitude: 24.6799, longitude: 118.1737}, hasLiveWaitTimes: false}, + {parkId: 33, name: 'Fantawild Water Park Wuhu', timezone: 'Asia/Shanghai', location: {latitude: 31.3594, longitude: 118.4618}, hasLiveWaitTimes: false}, + {parkId: 37, name: 'Fantawild Water Park Zhengzhou', timezone: 'Asia/Shanghai', location: {latitude: 34.7663, longitude: 113.9364}, hasLiveWaitTimes: false}, + {parkId: 39, name: 'Fantawild Adventure Tianjin', timezone: 'Asia/Shanghai', location: {latitude: 39.1555, longitude: 117.7395}, hasLiveWaitTimes: false}, + {parkId: 43, name: 'Fantawild Oriental Heritage Jinan', timezone: 'Asia/Shanghai', location: {latitude: 36.7065, longitude: 116.8781}, hasLiveWaitTimes: false}, + {parkId: 45, name: 'Fantawild Adventure Jiayuguan', timezone: 'Asia/Shanghai', location: {latitude: 39.7560, longitude: 98.3450}, hasLiveWaitTimes: false}, + {parkId: 47, name: 'Fantawild Adventure Datong', timezone: 'Asia/Shanghai', location: {latitude: 40.0599, longitude: 113.3676}, hasLiveWaitTimes: true}, + {parkId: 49, name: 'Fantawild Oriental Heritage Wuhu', timezone: 'Asia/Shanghai', location: {latitude: 31.3591, longitude: 118.4687}, hasLiveWaitTimes: false}, + {parkId: 51, name: 'Fantawild Dreamland Zhengzhou', timezone: 'Asia/Shanghai', location: {latitude: 34.7661, longitude: 113.9261}, hasLiveWaitTimes: false}, + {parkId: 53, name: 'Fantawild Oriental Heritage Ningbo', timezone: 'Asia/Shanghai', location: {latitude: 30.3199, longitude: 121.1824}, hasLiveWaitTimes: false}, + {parkId: 55, name: 'Fantawild Silk Road Heritage Jiayuguan', timezone: 'Asia/Shanghai', location: {latitude: 39.8030, longitude: 98.2454}, hasLiveWaitTimes: false}, + {parkId: 57, name: 'Fantawild Dreamland Zhuzhou', timezone: 'Asia/Shanghai', location: {latitude: 27.9844, longitude: 113.1917}, hasLiveWaitTimes: false}, + {parkId: 61, name: 'Fantawild Oriental Heritage Changsha', timezone: 'Asia/Shanghai', location: {latitude: 28.2021, longitude: 112.5932}, hasLiveWaitTimes: true}, + {parkId: 63, name: 'Fantawild Oriental Heritage Jingzhou', timezone: 'Asia/Shanghai', location: {latitude: 30.3901, longitude: 112.2400}, hasLiveWaitTimes: false}, + {parkId: 67, name: 'Fantawild Glory of Kungfu Handan', timezone: 'Asia/Shanghai', location: {latitude: 36.2856, longitude: 114.3927}, hasLiveWaitTimes: false}, + {parkId: 69, name: 'Fantawild Oriental Heritage Mianyang', timezone: 'Asia/Shanghai', location: {latitude: 31.7305, longitude: 104.7071}, hasLiveWaitTimes: false}, + {parkId: 71, name: 'Fantawild Oriental Heritage Xiamen', timezone: 'Asia/Shanghai', location: {latitude: 24.6801, longitude: 118.1722}, hasLiveWaitTimes: false}, + {parkId: 73, name: 'Fantawild Oriental Heritage Taiyuan', timezone: 'Asia/Shanghai', location: {latitude: 38.0467, longitude: 112.6505}, hasLiveWaitTimes: true}, + {parkId: 75, name: 'Fantawild Water Park Xiamen', timezone: 'Asia/Shanghai', location: {latitude: 24.6796, longitude: 118.1743}, hasLiveWaitTimes: false}, + {parkId: 77, name: "Fantawild Glorious Orient Ganzhou", timezone: 'Asia/Shanghai', location: {latitude: 25.9066, longitude: 114.9340}, hasLiveWaitTimes: true}, + {parkId: 79, name: 'Fantawild ASEAN Heritage Nanning', timezone: 'Asia/Shanghai', location: {latitude: 22.7638, longitude: 108.4160}, hasLiveWaitTimes: true}, + {parkId: 81, name: 'Fantawild Dinosaur Kingdom Zigong', timezone: 'Asia/Shanghai', location: {latitude: 29.4030, longitude: 104.8257}, hasLiveWaitTimes: false}, + {parkId: 83, name: 'Fantawild FT Wild Land Taizhou', timezone: 'Asia/Shanghai', location: {latitude: 28.5516, longitude: 121.5746}, hasLiveWaitTimes: false}, + {parkId: 85, name: "Fantawild Glorious Orient Huai'an", timezone: 'Asia/Shanghai', location: {latitude: 33.2680, longitude: 118.8390}, hasLiveWaitTimes: false}, + {parkId: 87, name: 'Fantawild Glorious Orient Jining', timezone: 'Asia/Shanghai', location: {latitude: 35.3352, longitude: 116.6941}, hasLiveWaitTimes: false}, + {parkId: 89, name: 'Fantawild Glorious Orient Ningbo', timezone: 'Asia/Shanghai', location: {latitude: 30.3258, longitude: 121.1794}, hasLiveWaitTimes: false}, + {parkId: 93, name: 'Fantawild Water Park Tianjin', timezone: 'Asia/Shanghai', location: {latitude: 39.1570, longitude: 117.7404}, hasLiveWaitTimes: false}, + {parkId: 95, name: "Boonie Bears Park Huai'an", timezone: 'Asia/Shanghai', location: {latitude: 33.2763, longitude: 118.8404}, hasLiveWaitTimes: true}, + {parkId: 97, name: 'Fantawild Oriental Heritage Yingtan', timezone: 'Asia/Shanghai', location: {latitude: 28.2888, longitude: 117.0340}, hasLiveWaitTimes: false}, + {parkId: 101, name: 'Boonie Bears Adventure Park Linhai', timezone: 'Asia/Shanghai', location: {latitude: 28.8602, longitude: 121.1950}, hasLiveWaitTimes: false}, + {parkId: 105, name: 'Fantawild Park Xuzhou', timezone: 'Asia/Shanghai', location: {latitude: 34.1480, longitude: 117.3605}, hasLiveWaitTimes: false}, + {parkId: 109, name: 'Boonie Bears Happy Harbor Ningbo', timezone: 'Asia/Shanghai', location: {latitude: 30.3211, longitude: 121.1717}, hasLiveWaitTimes: false}, + {parkId: 113, name: 'Fantawild Water Park Taizhou', timezone: 'Asia/Shanghai', location: {latitude: 28.5447, longitude: 121.5809}, hasLiveWaitTimes: false}, + {parkId: 115, name: 'Fantawild Water Park Xuzhou', timezone: 'Asia/Shanghai', location: {latitude: 34.1488, longitude: 117.3568}, hasLiveWaitTimes: false}, + {parkId: 117, name: 'Fantawild Water Park Yingtan', timezone: 'Asia/Shanghai', location: {latitude: 28.2872, longitude: 117.0302}, hasLiveWaitTimes: false}, + {parkId: 119, name: 'Boonie Bears Park Yichun', timezone: 'Asia/Shanghai', location: {latitude: 27.8186, longitude: 114.3378}, hasLiveWaitTimes: true}, + {parkId: 121, name: 'Fantawild Water Park Yichun', timezone: 'Asia/Shanghai', location: {latitude: 27.8248, longitude: 114.3387}, hasLiveWaitTimes: false}, + {parkId: 127, name: 'Fantawild Glory of Kungfu Ziyang', timezone: 'Asia/Shanghai', location: {latitude: 30.1904, longitude: 104.5763}, hasLiveWaitTimes: false}, + {parkId: 129, name: 'Fantawild FT Wild Land Xiaogan', timezone: 'Asia/Shanghai', location: {latitude: 30.8198, longitude: 114.1074}, hasLiveWaitTimes: true}, + {parkId: 131, name: 'Boonie Bears Water Park Linhai', timezone: 'Asia/Shanghai', location: {latitude: 28.8601, longitude: 121.1962}, hasLiveWaitTimes: false}, + {parkId: 135, name: 'Fantawild Water Park Ganzhou', timezone: 'Asia/Shanghai', location: {latitude: 25.9100, longitude: 114.9334}, hasLiveWaitTimes: false}, + {parkId: 137, name: 'Fantawild Water World Ziyang', timezone: 'Asia/Shanghai', location: {latitude: 30.1886, longitude: 104.5732}, hasLiveWaitTimes: false}, + {parkId: 139, name: 'Fantawild Water Park Xiaogan', timezone: 'Asia/Shanghai', location: {latitude: 30.8182, longitude: 114.1067}, hasLiveWaitTimes: false}, +] as const; // ── API types ─────────────────────────────────────────────────────────────── @@ -67,28 +167,6 @@ export interface FantawildBusinessTimeResponse { version?: string; } -export interface FantawildCityParkEntry { - parkName: string; - parkTypeName?: string; - picUrl?: string; - logoPicUrl?: string; - id: number; - poiId?: string; - poiCode?: number; - poiCategory?: number; - lngLong?: Array<{latitude: number; longitude: number; sort: number}>; - waitTimeAreaLngLong?: Array<{latitude: number; longitude: number; sort: number}>; - onLineChannelList?: number[]; -} - -export interface FantawildCityEntry { - id: number; - cityName: string; - cityNameSpell?: string; - cityCode?: string; - parkList: FantawildCityParkEntry[]; -} - /** One ride/show entry from the `GetItemBusinessList` endpoint. */ export interface FantawildItem { parkId: number; @@ -124,8 +202,11 @@ export interface FantawildItemListResponse { // ── Parsers ───────────────────────────────────────────────────────────────── -/** Trailing star-rating glyphs Fantawild bakes into itemName (e.g. `孟姜女⭐⭐⭐⭐`). */ -const STAR_RE = /[⭐⭐️]+\s*$/u; +/** + * Trailing star-rating glyphs Fantawild bakes into itemName (e.g. `孟姜女⭐⭐⭐⭐`). + * Matches U+2B50 ⭐ with an optional U+FE0F variation selector after each. + */ +const STAR_RE = /(?:⭐️?)+\s*$/u; /** Strip trailing star-rating glyphs from a Fantawild item name. */ export function stripFantawildStars(name: string): string { @@ -141,12 +222,9 @@ export function isFantawildShow(item: FantawildItem): boolean { if (times.length === 0) return false; // If every entry is a single time (no dash range) it's a discrete-showtime SHOW. // A single "HH:MM-HH:MM" range is the operating-hours pattern used for attractions. - const allDiscrete = times.every(t => /^\d{1,2}:\d{2}$/.test(t.trim())); - return allDiscrete && times.length >= 1; + return times.every(t => /^\d{1,2}:\d{2}$/.test(t.trim())); } -// ── Schedule parser ───────────────────────────────────────────────────────── - /** * Parse "HH:MM" to minutes-from-midnight. Returns NaN if malformed. * @@ -180,6 +258,9 @@ function closeDateAcrossMidnight(date: string, openTime: string, closeTime: stri const opens = hhmmToMinutes(openTime); const closes = hhmmToMinutes(closeTime); if (!Number.isFinite(opens) || !Number.isFinite(closes)) return date; + // `closes > opens` would leave the equality case (e.g. 09:00-09:00) folded + // into a zero-length same-day window; downstream consumers prefer the + // 24-hour-window interpretation, so equal times also roll to next day. if (closes > opens) return date; // YYYY-MM-DD → next day. Date.UTC handles month/year rollover correctly. const [y, m, d] = date.split('-').map(Number); @@ -233,117 +314,87 @@ export function parseBusinessTime( return out; } -// ── Base class ────────────────────────────────────────────────────────────── +// ── Destination class ─────────────────────────────────────────────────────── -class Fantawild extends Destination { +@destinationController({category: 'Fantawild'}) +export class Fantawild extends Destination { /** Static-asset CDN root, e.g. `https://image.fangte.com` (no trailing slash). */ @config baseUrl: string = ''; /** Authenticated API root, e.g. `https://leyou.fangte.com` (no trailing slash). */ @config apiBaseUrl: string = ''; - /** Top-level destination id, e.g. `fantawild_wuhudreamland` */ - @config destinationId: string = ''; - /** Display name for the DESTINATION entity */ - @config destinationName: string = ''; - /** The small numeric parkId Fantawild assigns (17, 19, 21, …) — keyed in BusinessTime URL. */ - @config parkId: number = 0; - /** IANA timezone for the destination. Defaults to Asia/Shanghai (mainland China). */ - @config timezone: string = 'Asia/Shanghai'; - - /** Destination-level geographic location (lat/lng). */ - destinationLocation?: {latitude: number; longitude: number}; /** - * Route-prefix constants for the static CDN paths. These are baked into - * the app and only change when Fantawild ships new versioned route maps. - * Override per-subclass if a future app rev splits parks across cohorts. + * Route-prefix constant for the BusinessTime CDN path. Baked into the + * app and only changes when Fantawild ships a new versioned route map. */ protected businessTimeVersion: string = '50418'; - protected announcementVersion: string = '50418'; - protected cityParkVersion: string = '50622'; constructor(options?: DestinationConstructor) { super(options); this.addConfigPrefix('FANTAWILD'); - const cfg = (options?.config ?? {}) as Partial; - if (cfg.destinationLocation) this.destinationLocation = cfg.destinationLocation; } - /** Cache-key prefix so per-park caches don't collide on shared method keys. */ + /** All caches keyed methods take parkId as an argument, so the prefix is constant. */ getCacheKeyPrefix(): string { - return `fantawild:${this.parkId}`; + return 'fantawild'; } protected async _init(): Promise { - const cls = this.constructor.name.toUpperCase(); if (!this.baseUrl) { throw new Error( - `${this.constructor.name} requires baseUrl to be configured ` + - `(set ${cls}_BASEURL or FANTAWILD_BASEURL in .env, e.g. https://image.fangte.com)`, + 'Fantawild requires baseUrl to be configured ' + + '(set FANTAWILD_BASEURL in .env, e.g. https://image.fangte.com)', ); } if (!this.apiBaseUrl) { throw new Error( - `${this.constructor.name} requires apiBaseUrl to be configured ` + - `(set ${cls}_APIBASEURL or FANTAWILD_APIBASEURL in .env, e.g. https://leyou.fangte.com)`, + 'Fantawild requires apiBaseUrl to be configured ' + + '(set FANTAWILD_APIBASEURL in .env, e.g. https://leyou.fangte.com)', ); } - if (!this.parkId) { - throw new Error(`${this.constructor.name} requires a numeric parkId to be configured`); - } - if (!this.destinationId) { - throw new Error(`${this.constructor.name} requires destinationId to be configured`); - } } - // ===== HTTP ===== + // ── ID derivation (matches SixFlags pattern) ────────────────────────────── - /** Master park list (all 50 Fantawild parks across all cities). Shared across destinations; cache aggressively. */ - @http({cacheSeconds: 60 * 60 * 6, retries: 2}) - async fetchCityPark(): Promise { - return { - method: 'GET', - url: `${this.baseUrl}/UploadFiles/Launch/CityPark/${this.cityParkVersion}.json`, - options: {json: true}, - } as unknown as HTTPObj; + protected destinationIdFor(parkId: number): string { return `fantawild_destination_${parkId}`; } + protected parkIdFor(parkId: number): string { return `fantawild_park_${parkId}`; } + protected attractionIdFor(parkId: number, itemId: number): string { + return `fantawild_attraction_${parkId}_${itemId}`; } - /** Per-park daily opening hours. */ - @http({cacheSeconds: 60 * 15, retries: 2}) - async fetchBusinessTime(): Promise { - return { - method: 'GET', - url: `${this.baseUrl}/UploadFiles/Launch/BusinessTime/${this.businessTimeVersion}/${this.parkId}.json`, - options: {json: true}, - } as unknown as HTTPObj; - } + // ── HTTP ────────────────────────────────────────────────────────────────── - /** Per-park announcements. Phase 1 doesn't surface them, but kept for future use. */ - @http({cacheSeconds: 60 * 30, retries: 2}) - async fetchAnnouncements(): Promise { + /** Per-park daily opening hours. parkId in the argument keys the @http cache per park. */ + @http({cacheSeconds: 60 * 15, retries: 2}) + async fetchBusinessTime(parkId: number): Promise { return { method: 'GET', - url: `${this.baseUrl}/UploadFiles/Launch/Announcement/${this.announcementVersion}/${this.parkId}.json`, + url: `${this.baseUrl}/UploadFiles/Launch/BusinessTime/${this.businessTimeVersion}/${parkId}.json`, options: {json: true}, } as unknown as HTTPObj; } /** * Per-park ride + show list with live wait times. This endpoint is on the - * separate authenticated host `leyou.fangte.com` rather than the CDN; the + * separate API host `leyou.fangte.com` rather than the CDN; the * `selectedDate` query param is required by the server (controls which * day's show times are reported) but the auth headers we observed in the * mobile app are NOT enforced — anonymous GETs return the same payload. * * Cache 60s — short enough for live-wait freshness, long enough to throttle. + * The selectedDate baked into the URL changes per second; the wrapping + * `@cache` on `getItems()` provides the cross-call dedup. */ @http({cacheSeconds: 60, retries: 2}) - async fetchItemBusinessList(): Promise { + async fetchItemBusinessList(parkId: number, timezone: string): Promise { // Wall-clock in the park's local timezone, formatted the way the app does: - // `YYYY-MM-DD HH:MM:SS.ffffff`. The server reads only the date portion in - // practice, but mirroring the app's format keeps us in the same code path - // on the server side. - const now = formatInTimezone(new Date(), this.timezone, 'iso').slice(0, 19).replace('T', ' '); - const selectedDate = `${now}.000000`; + // `YYYY-MM-DD HH:MM:SS.ffffff`. Round to the minute so the @http cache + // key stays stable for ~60s — otherwise every call shifts the URL and + // every request misses the upstream cache. Server reads only the date + // portion in practice; rounding is safe. + const minuteBoundary = new Date(Math.floor(Date.now() / 60_000) * 60_000); + const stamp = formatInTimezone(minuteBoundary, timezone, 'iso').slice(0, 19).replace('T', ' '); + const selectedDate = `${stamp}.000000`; const params = new URLSearchParams({ sortType: '1', SuitablePeopleTag: '', @@ -352,7 +403,7 @@ class Fantawild extends Destination { PayProperties: '', FunctionType: '', height: '0.0', - parkId: String(this.parkId), + parkId: String(parkId), selectedDate, }); return { @@ -362,12 +413,34 @@ class Fantawild extends Destination { } as unknown as HTTPObj; } - // ===== Schedule scraping ===== + // ── Cached parsed data ──────────────────────────────────────────────────── + + /** + * Fetch + return the parsed item list for one park. Returns [] on failure + * so an outage on one park doesn't take out the whole 50-park sweep. + * + * Cached 60s by `parkId` — `buildEntityList` and `buildLiveData` both + * call this for every park on the same tick; the @cache wrap dedupes + * even though `fetchItemBusinessList`'s @http cache key shifts per + * minute due to the rounded `selectedDate`. + * + * `cacheVersion: 1` is set explicitly so future shape changes (new fields + * surfaced, status mapping rewrites) can bump it and silently invalidate + * stale entries across deploys without manual flushes. + */ + @cache({ttlSeconds: 60, cacheVersion: 1}) + async getItems(parkId: number, timezone: string): Promise { + try { + const resp = await this.fetchItemBusinessList(parkId, timezone); + const json = await resp.json() as FantawildItemListResponse; + return json?.data ?? []; + } catch { + return []; + } + } /** - * Fetch + parse the next ~7-10 days of opening hours. Returns [] on any - * fetch/parse failure so an outage on one park doesn't take out a multi- - * park sweep. + * Fetch + parse the next ~7-10 days of opening hours for one park. * * Dynamic cache TTL: 6h for a populated result (BusinessTime is a * forward-looking calendar that rarely changes intra-day, so a long TTL @@ -375,132 +448,212 @@ class Fantawild extends Destination { * 60s if the result is empty — a transient CDN/parse failure shouldn't * stick around as a fabricated zero-day schedule for hours. */ - @cache({callback: (result: ScheduleEntry[]) => result.length === 0 ? 60 : 60 * 60 * 6}) - async scrapeSchedule(): Promise { + @cache({ + callback: (result: ScheduleEntry[]) => result.length === 0 ? 60 : 60 * 60 * 6, + cacheVersion: 1, + }) + async getSchedule(parkId: number, timezone: string): Promise { try { - const resp = await this.fetchBusinessTime(); + const resp = await this.fetchBusinessTime(parkId); const json = await resp.json() as FantawildBusinessTimeResponse; - return parseBusinessTime(json, this.timezone); + return parseBusinessTime(json, timezone); } catch { return []; } } /** - * Fetch + return the raw item list. Returns [] on any failure. + * Permissive write-once flag tracking whether a park has EVER returned a + * `waitTime > 0` in production. Combined with the static `hasLiveWaitTimes` + * config flag via OR: once we observe a real queue, we mark the park as + * live-wait-broadcasting forever (or until the SQLite cache file is + * deleted). New Fantawild parks that roll out live waits after this PR + * ships will self-correct without manual flag flips. + * + * Per the `feedback_cache_only_true.md` rule: only ever write TRUE to + * this cache, never FALSE — a single quiet observation must not lock + * the park into "no live waits" until cache expiry. * - * Cached 60s. `fetchItemBusinessList()` bakes the wall-clock into the - * `selectedDate` query string, so its underlying `@http` cache key - * changes every second and wouldn't dedupe the calls fired by - * `buildEntityList()` and `buildLiveData()` in the same tick. The - * argless `@cache` here keys on class + method only, so both builders - * share one payload per parkId per 60s window. + * Returns true if any item has `waitTime > 0`, and persists that fact + * for 90 days (the cache file outlives any individual deploy). */ - @cache({ttlSeconds: 60}) - async fetchItems(): Promise { - try { - const resp = await this.fetchItemBusinessList(); - const json = await resp.json() as FantawildItemListResponse; - return json?.data ?? []; - } catch { - return []; + protected async recordLiveWaitObservation(parkId: number, items: FantawildItem[]): Promise { + const key = `${this.getCacheKeyPrefix()}:liveWaitsObserved:v1:${parkId}`; + if (CacheLib.get(key) === true) return true; + const observed = items.some(i => Number.isFinite(i.waitTime) && i.waitTime > 0); + if (observed) { + CacheLib.set(key, true, 60 * 60 * 24 * 90); + return true; } + return false; } - /** Build a stable attraction id from a Fantawild ride id. */ - protected attractionId(itemId: number): string { - return `fantawild_attraction_${this.parkId}_${itemId}`; + /** + * Is the park currently open per its BusinessTime schedule? + * + * The live API's `itemOpened` flag is set by the operator's data-ops + * team and tracks "in-season ride availability," not "is the gate open + * right now" — at 5 AM China time we observed every ride at Wuhu + * Dreamland reporting `itemOpened: true` even though the park was + * closed for the night. Cross-checking against BusinessTime turns that + * into the correct CLOSED status. + */ + protected parkIsOpenNow(schedule: readonly ScheduleEntry[], timezone: string): boolean { + const now = new Date(); + const today = formatDate(now, timezone); + const nowMs = now.getTime(); + // Pick today's OPERATING window. The schedule may also contain + // EXTRA_HOURS (night events); both qualify as "park open." + for (const entry of schedule) { + if (entry.date !== today) continue; + if (entry.type !== 'OPERATING' && entry.type !== 'EXTRA_HOURS') continue; + const open = Date.parse(entry.openingTime); + const close = Date.parse(entry.closingTime); + if (Number.isFinite(open) && Number.isFinite(close) && nowMs >= open && nowMs < close) { + return true; + } + } + return false; } - // ===== Public-API overrides ===== + // ── Public-API overrides (loop FANTAWILD_PARKS, mirror SixFlags shape) ──── async getDestinations(): Promise { - const dest: Entity = { - id: this.destinationId, - name: this.destinationName, + // Force env-validation BEFORE returning destinations. Otherwise 49 ghost + // destinations get registered with the wiki and every subsequent live-data + // poll fails with the same misleading "request failed" error. The base + // getEntities()/getLiveData()/getSchedules() already call init(); mirror + // that here so getDestinations() fails the same way. + await this.init(); + return FANTAWILD_PARKS.map(park => ({ + id: this.destinationIdFor(park.parkId), + name: park.name, entityType: 'DESTINATION', - timezone: this.timezone, - } as Entity; - if (this.destinationLocation) { - (dest as Entity & {location?: {latitude: number; longitude: number}}).location = this.destinationLocation; - } - return [dest]; + timezone: park.timezone, + location: park.location, + } as Entity)); } + /** + * Fan out: 49 parks × ~30 items each ≈ 1500 entities. Parallel fetch + * works against the public API (observed comfortably with 50 concurrent + * probes during discovery), but the shared @http queue throttles to + * one request per 100ms anyway — so this is closer to staggered than + * truly parallel. @reusable() coalesces in-flight calls so a collector + * burst doesn't multiply work. + */ + @reusable() protected async buildEntityList(): Promise { - const parkId = `fantawild_park_${this.parkId}`; - const parkEntity: Entity = { - id: parkId, - name: this.destinationName, - entityType: 'PARK', - parentId: this.destinationId, - destinationId: this.destinationId, - timezone: this.timezone, - } as Entity; - if (this.destinationLocation) { - (parkEntity as Entity & {location?: {latitude: number; longitude: number}}).location = this.destinationLocation; - } - - const out: Entity[] = [parkEntity]; - const items = await this.fetchItems(); - for (const item of items) { - // Skip entries with no usable id or name. - if (!item.id) continue; - const cleanName = stripFantawildStars(item.itemName || ''); - if (!cleanName) continue; - const entityType = isFantawildShow(item) ? 'SHOW' : 'ATTRACTION'; - const entity: Entity = { - id: this.attractionId(item.id), - name: cleanName, - entityType, - parentId: parkId, - parkId, - destinationId: this.destinationId, - timezone: this.timezone, - } as Entity; - if (Number.isFinite(item.latitude) && Number.isFinite(item.longitude)) { - (entity as Entity & {location?: {latitude: number; longitude: number}}).location = { - latitude: item.latitude!, - longitude: item.longitude!, - }; + const perPark = await Promise.all(FANTAWILD_PARKS.map(async park => { + const destinationId = this.destinationIdFor(park.parkId); + const parkEntityId = this.parkIdFor(park.parkId); + const entities: Entity[] = []; + + entities.push({ + id: destinationId, + name: park.name, + entityType: 'DESTINATION', + timezone: park.timezone, + location: park.location, + } as Entity); + + entities.push({ + id: parkEntityId, + name: park.name, + entityType: 'PARK', + parentId: destinationId, + destinationId, + timezone: park.timezone, + location: park.location, + } as Entity); + + const items = await this.getItems(park.parkId, park.timezone); + for (const item of items) { + if (!item.id) continue; + const cleanName = stripFantawildStars(item.itemName || ''); + if (!cleanName) continue; + const isShow = isFantawildShow(item); + const entity: Entity = { + id: this.attractionIdFor(park.parkId, item.id), + name: cleanName, + entityType: isShow ? 'SHOW' : 'ATTRACTION', + parentId: parkEntityId, + parkId: parkEntityId, + destinationId, + timezone: park.timezone, + } as Entity; + // Subtype the ATTRACTION entities for consumers that distinguish + // RIDE vs other attraction kinds (matches the SixFlags pattern). + if (!isShow) { + (entity as Entity & {attractionType?: string}).attractionType = 'RIDE'; + } + if (Number.isFinite(item.latitude) && Number.isFinite(item.longitude)) { + (entity as Entity & {location?: {latitude: number; longitude: number}}).location = { + latitude: item.latitude!, + longitude: item.longitude!, + }; + } + entities.push(entity); } - out.push(entity); - } - return out; + return entities; + })); + return perPark.flat(); } + @reusable() protected async buildLiveData(): Promise { - const items = await this.fetchItems(); - const out: LiveData[] = []; - for (const item of items) { - if (!item.id) continue; - const isOpen = item.itemOpened === true; - // `项目维护` ("under maintenance") explicitly flags a planned closure - // distinct from "closed because the park's closed" — surface as REFURBISHMENT. - const isMaintenance = !!item.statusStr && /维护/.test(item.statusStr); - const status = isMaintenance ? 'REFURBISHMENT' : (isOpen ? 'OPERATING' : 'CLOSED'); - const ld: LiveData = { - id: this.attractionId(item.id), - status, - } as LiveData; - // Only attach a STANDBY wait time when the ride is OPERATING and the - // API returned a finite, non-negative value. Avoid emitting waitTime=0 - // for closed rides — `0` should mean "no queue right now," not "park - // closed so we don't actually know." - if (status === 'OPERATING' && Number.isFinite(item.waitTime) && item.waitTime >= 0) { - (ld as LiveData & {queue?: Record}).queue = { - STANDBY: {waitTime: item.waitTime}, - }; + const perPark = await Promise.all(FANTAWILD_PARKS.map(async park => { + // Fetch items + schedule in parallel for this park, then cross-check. + const [items, schedule] = await Promise.all([ + this.getItems(park.parkId, park.timezone), + this.getSchedule(park.parkId, park.timezone), + ]); + const parkOpen = this.parkIsOpenNow(schedule, park.timezone); + // Pick up runtime evidence that this park does broadcast live waits, + // OR'd with the curated static flag. New parks light up automatically. + const liveWaitsOn = park.hasLiveWaitTimes + || await this.recordLiveWaitObservation(park.parkId, items); + const out: LiveData[] = []; + for (const item of items) { + if (!item.id) continue; + const isOpen = item.itemOpened === true; + // `项目维护` ("under maintenance") explicitly flags a planned closure + // distinct from "closed because the park's closed" — surface as REFURBISHMENT. + const isMaintenance = !!item.statusStr && /维护/.test(item.statusStr); + // Park-closed wins: even if itemOpened is true, the gate isn't open + // — emit CLOSED rather than report ghost-OPERATING status all night. + const status = !parkOpen + ? 'CLOSED' + : (isMaintenance ? 'REFURBISHMENT' : (isOpen ? 'OPERATING' : 'CLOSED')); + const ld: LiveData = { + id: this.attractionIdFor(park.parkId, item.id), + status, + } as LiveData; + // Only emit STANDBY queue for parks that broadcast live wait times + // (per curated config OR runtime observation). Otherwise `waitTime: 0` + // would lie — every ride at a non-live park would report a permanent + // zero-minute queue. + if (liveWaitsOn + && status === 'OPERATING' + && Number.isFinite(item.waitTime) + && item.waitTime >= 0) { + (ld as LiveData & {queue?: Record}).queue = { + STANDBY: {waitTime: item.waitTime}, + }; + } + out.push(ld); } - out.push(ld); - } - return out; + return out; + })); + return perPark.flat(); } + @reusable() protected async buildSchedules(): Promise { - const schedule = await this.scrapeSchedule(); - return [{id: `fantawild_park_${this.parkId}`, schedule} as EntitySchedule]; + const out = await Promise.all(FANTAWILD_PARKS.map(async park => { + const schedule = await this.getSchedule(park.parkId, park.timezone); + return {id: this.parkIdFor(park.parkId), schedule} as EntitySchedule; + })); + return out; } } - -export {Fantawild}; diff --git a/src/parks/fantawild/wuhudreamland.ts b/src/parks/fantawild/wuhudreamland.ts deleted file mode 100644 index b1c2451a..00000000 --- a/src/parks/fantawild/wuhudreamland.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {Fantawild} from './fantawild.js'; -import {destinationController} from '../../destinationRegistry.js'; -import type {DestinationConstructor} from '../../destination.js'; - -@destinationController({category: ['Fantawild']}) -export class WuhuDreamland extends Fantawild { - constructor(options?: DestinationConstructor) { - super({ - ...options, - config: { - destinationId: 'fantawild_wuhudreamland', - destinationName: 'Fantawild Dreamland Wuhu', - timezone: 'Asia/Shanghai', - ...(options?.config ?? {}), - }, - }); - // parkId is numeric and DestinationConstructor.config is string-only, so - // assign it directly. The @config decorator still honours an env-var - // override (FANTAWILD_PARKID / WUHUDREAMLAND_PARKID); only fall back to - // the literal when nothing set it. - if (!this.parkId) this.parkId = 19; - this.destinationLocation ??= {latitude: 31.3599, longitude: 118.4582}; - } -} From d78075233950c0737572be06aee21d6542f0a93e Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 21 Jun 2026 08:49:53 +0000 Subject: [PATCH 8/9] fix(fantawild): parkIsOpenNow honours post-midnight tail + missing test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass review caught a real regression in the schedule cross-check added by the prior commit: - parkIsOpenNow filtered schedule entries by `entry.date === today`, but a night-event window opened on date N closes on date N+1 (e.g. 22:00 -> 01:00). At 00:30 on date N+1 the loop skipped the matching entry and reported CLOSED while the park was still open. Drop the date filter — the openingTime/closingTime are absolute ISO instants, so comparing nowMs against them is unambiguous regardless of which calendar date the entry is tagged with. Same pattern as Europa-Park #224 but applied to the now-check rather than the schedule emitter. - Added unit tests for parkIsOpenNow covering: no-match, mid-window, the OPERATING/EXTRA_HOURS gap, EXTRA_HOURS night event, post-midnight tail (pins the fix above), and malformed timestamps. - Added unit tests for recordLiveWaitObservation covering the four invariants from feedback_cache_only_true.md: no false-positive write, TRUE persists across calls, per-parkId namespacing, non-finite waitTime values ignored. - Documented the precedence rule on isFantawildShow (live-performance / parade feature tags win unconditionally, even with range-shaped showTimeList). Co-Authored-By: Claude Opus 4.7 --- .../fantawild/__tests__/fantawild.test.ts | 139 +++++++++++++++++- src/parks/fantawild/fantawild.ts | 26 ++-- 2 files changed, 155 insertions(+), 10 deletions(-) diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts index 7703d565..7835bb2d 100644 --- a/src/parks/fantawild/__tests__/fantawild.test.ts +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -1,4 +1,4 @@ -import {describe, test, expect} from 'vitest'; +import {describe, test, expect, beforeAll} from 'vitest'; import { parseBusinessTime, stripFantawildStars, @@ -327,3 +327,140 @@ describe('isFantawildShow', () => { }); }); +describe('Fantawild.parkIsOpenNow', () => { + // We test the protected helper directly via casting — it has subtle edge cases + // (the post-midnight tail in particular) that need explicit coverage. + let dest: import('../fantawild.js').Fantawild; + + // ScheduleEntry openingTime/closingTime are absolute ISO strings with offset, + // so parkIsOpenNow can be tested against any current Date.now() — we craft + // entries whose windows straddle / surround / miss "now" relative to the + // real clock at test time, with no need for fake timers. + const nowIso = (offsetMinutes: number): string => { + const ms = Date.now() + offsetMinutes * 60_000; + // emit as +08:00 since `parkIsOpenNow` ignores the offset and parses + // absolute moments — any well-formed offset works. + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:00Z`; + }; + + beforeAll(async () => { + const {Fantawild} = await import('../fantawild.js'); + dest = new Fantawild({config: { + baseUrl: 'https://image.fangte.com', + apiBaseUrl: 'https://leyou.fangte.com', + }}); + }); + + test('returns false when no schedule covers now', () => { + const sched = [{ + date: '2020-01-01', type: 'OPERATING' as const, + openingTime: nowIso(-120 * 24 * 60), closingTime: nowIso(-119 * 24 * 60), + }]; + expect((dest as any).parkIsOpenNow(sched)).toBe(false); + }); + + test('returns true when an OPERATING window contains now', () => { + const sched = [{ + date: '2026-06-21', type: 'OPERATING' as const, + openingTime: nowIso(-60), closingTime: nowIso(+60), + }]; + expect((dest as any).parkIsOpenNow(sched)).toBe(true); + }); + + test('returns false when now falls in the gap between two windows', () => { + const sched = [ + {date: '2026-06-21', type: 'OPERATING' as const, + openingTime: nowIso(-180), closingTime: nowIso(-60)}, + {date: '2026-06-21', type: 'EXTRA_HOURS' as const, + openingTime: nowIso(+60), closingTime: nowIso(+180)}, + ]; + expect((dest as any).parkIsOpenNow(sched)).toBe(false); + }); + + test('returns true when EXTRA_HOURS night event contains now', () => { + const sched = [ + {date: '2026-06-21', type: 'OPERATING' as const, + openingTime: nowIso(-9 * 60), closingTime: nowIso(-3 * 60)}, + {date: '2026-06-21', type: 'EXTRA_HOURS' as const, + openingTime: nowIso(-30), closingTime: nowIso(+30)}, + ]; + expect((dest as any).parkIsOpenNow(sched)).toBe(true); + }); + + test('honours post-midnight tail: window opened yesterday, closes today', () => { + // The schedule entry's date is YESTERDAY (when the window opened), + // but its closingTime is on TODAY's calendar date because the window + // crosses midnight (e.g. 22:00 → 01:00). Earlier versions filtered by + // `entry.date === today` and missed the tail; this test pins the fix. + const sched = [{ + date: '2026-06-20', // a fixed past date — value doesn't matter + type: 'OPERATING' as const, + openingTime: nowIso(-90), + closingTime: nowIso(+30), + }]; + expect((dest as any).parkIsOpenNow(sched)).toBe(true); + }); + + test('treats malformed openingTime/closingTime as a non-match instead of throwing', () => { + const sched = [{ + date: '2026-06-21', type: 'OPERATING' as const, + openingTime: 'not a date', closingTime: 'also not', + }] as readonly any[]; + expect((dest as any).parkIsOpenNow(sched)).toBe(false); + }); +}); + +describe('Fantawild.recordLiveWaitObservation', () => { + // Verify the "Cache only TRUE, never FALSE" invariant (per the + // feedback_cache_only_true.md memory). + let dest: import('../fantawild.js').Fantawild; + + beforeAll(async () => { + const {Fantawild} = await import('../fantawild.js'); + dest = new Fantawild({config: { + baseUrl: 'https://image.fangte.com', + apiBaseUrl: 'https://leyou.fangte.com', + }}); + }); + + // Use a unique parkId per test so we don't see leakage from other tests + // (the cache is process-global). Sequential within this describe. + const PARK_A = 9_000_001; + const PARK_B = 9_000_002; + const PARK_C = 9_000_003; + + test('returns false when no item has waitTime > 0 and writes nothing', async () => { + const items = [{waitTime: 0}, {waitTime: 0}] as any; + const result = await (dest as any).recordLiveWaitObservation(PARK_A, items); + expect(result).toBe(false); + // Re-running with no positive observation must still return false (no + // sticky cached FALSE). + expect(await (dest as any).recordLiveWaitObservation(PARK_A, items)).toBe(false); + }); + + test('returns true when an item has waitTime > 0 and remembers permanently', async () => { + const observed = [{waitTime: 0}, {waitTime: 25}] as any; + expect(await (dest as any).recordLiveWaitObservation(PARK_B, observed)).toBe(true); + // Subsequent zero-only sweep must still return true — once observed, + // the park stays marked as live-broadcasting. + const zeros = [{waitTime: 0}, {waitTime: 0}] as any; + expect(await (dest as any).recordLiveWaitObservation(PARK_B, zeros)).toBe(true); + }); + + test('caches per parkId — one park observing live waits does not affect another', async () => { + const observed = [{waitTime: 15}] as any; + expect(await (dest as any).recordLiveWaitObservation(PARK_C, observed)).toBe(true); + // A different parkId starts fresh + const freshPark = 9_000_004; + expect(await (dest as any).recordLiveWaitObservation(freshPark, [{waitTime: 0}] as any)).toBe(false); + }); + + test('ignores non-finite waitTime values', async () => { + const garbage = [{waitTime: NaN}, {waitTime: undefined}] as any; + const fresh = 9_000_005; + expect(await (dest as any).recordLiveWaitObservation(fresh, garbage)).toBe(false); + }); +}); + diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index 02f93e98..881022e9 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -213,10 +213,17 @@ export function stripFantawildStars(name: string): string { return name.replace(STAR_RE, '').trim(); } -/** Classify an item as SHOW vs RIDE based on showTimeList shape + feature tags. */ +/** + * Classify an item as SHOW vs RIDE based on showTimeList shape + feature tags. + * + * Precedence: explicit `真人表演` (live performance) / `巡游` (parade) tags + * win unconditionally — even if showTimeList contains an `HH:MM-HH:MM` range + * (operating-hours pattern for attractions), an item carrying one of those + * tags is a SHOW. Falls back to showTimeList shape inspection otherwise. + */ export function isFantawildShow(item: FantawildItem): boolean { const features = item.featureList ?? []; - // Explicit live-performance / parade feature flags. + // Explicit live-performance / parade feature flags (highest priority). if (features.includes('真人表演') || features.includes('巡游')) return true; const times = item.showTimeList ?? []; if (times.length === 0) return false; @@ -498,14 +505,15 @@ export class Fantawild extends Destination { * closed for the night. Cross-checking against BusinessTime turns that * into the correct CLOSED status. */ - protected parkIsOpenNow(schedule: readonly ScheduleEntry[], timezone: string): boolean { - const now = new Date(); - const today = formatDate(now, timezone); - const nowMs = now.getTime(); - // Pick today's OPERATING window. The schedule may also contain - // EXTRA_HOURS (night events); both qualify as "park open." + protected parkIsOpenNow(schedule: readonly ScheduleEntry[], _timezone?: string): boolean { + const nowMs = Date.now(); + // No date filter — the schedule entries' openingTime/closingTime are + // absolute moments (full ISO with offset). Filtering by `entry.date` + // would skip a window that opened yesterday and closes after midnight + // (e.g. a 22:00 → 01:00 night event): `parseBusinessTime` tags those + // with yesterday's `date` even though the close has rolled into today. + // Comparing absolute ms covers every variation without bookkeeping. for (const entry of schedule) { - if (entry.date !== today) continue; if (entry.type !== 'OPERATING' && entry.type !== 'EXTRA_HOURS') continue; const open = Date.parse(entry.openingTime); const close = Date.parse(entry.closingTime); From 6c023107fb8899d3764efb83b7078c75ff1fb07c Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Mon, 22 Jun 2026 13:34:58 +0000 Subject: [PATCH 9/9] feat(fantawild): never-shrink roster cache survives upstream pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 25-hour live-API probe campaign turned up three real ways Fantawild's upstream API drops a park's roster to a fraction of its real ride list: 1. Overnight CMS pruning between China midnight and ~09:30: ~20 parks lose 1-6 rides each, and 3-4 parks go nearly empty (parkId 27 28→2, parkId 105 39→1, parkId 37 22→0). Persists for hours, stable across probes. 2. Slow-API timeouts caught by `getItems`' try/catch and turned into `[]` — same shape as a real empty roster. 3. Scheduled day-closures: BusinessTime carries `activated:false` for the day and `GetItemBusinessList` returns `data:[]` for the whole day (observed: parkId 49 on 2026-06-22). In all three cases the prior fix would shrink `buildEntityList()`'s output to match the broken roster — the wiki would interpret the missing entities as orphans and queue mass deletions. The fix introduces `getStableRoster(parkId, timezone)`: - Merges fresh API items + previously-cached items by `id`. New rides surface immediately; cached rides persist when fresh omits them. - Persists the merged result for 7 days (longer than typical weekly Mon-Wed park closures so multi-day closures stay protected), but only when the merged size is ≥ the cached size — the "never shrink within TTL" guarantee. A sustained reduction past the TTL eventually lets the cache entry expire, making the smaller roster the new baseline; this is how legitimately deleted rides clear over time. - Always overwrites cached entries with fresh on matching id, so name and coordinate updates surface immediately even when the roster shape stays the same. - Pattern matches the existing `recordLiveWaitObservation` write-once- TRUE design and the `feedback_cache_only_true.md` guidance. buildEntityList now reads from the stable roster. buildLiveData reads BOTH fresh items (for waitTime/itemOpened/statusStr) and the stable roster (for entity-level CLOSED emission when a roster ride is missing from this tick's fresh response). The wiki sees a consistent ride count + correct status throughout overnight pruning windows. Adds 5 unit tests covering: empty-cache first-call, never-shrink-within- TTL, merge-grow, fresh-overwrites-cached on matching id, and the slow-API/closed-today empty-fresh case. 1278 tests total. Co-Authored-By: Claude Opus 4.7 --- .../fantawild/__tests__/fantawild.test.ts | 70 +++++++++++++++ src/parks/fantawild/fantawild.ts | 85 +++++++++++++++++-- 2 files changed, 149 insertions(+), 6 deletions(-) diff --git a/src/parks/fantawild/__tests__/fantawild.test.ts b/src/parks/fantawild/__tests__/fantawild.test.ts index 7835bb2d..cd58d788 100644 --- a/src/parks/fantawild/__tests__/fantawild.test.ts +++ b/src/parks/fantawild/__tests__/fantawild.test.ts @@ -464,3 +464,73 @@ describe('Fantawild.recordLiveWaitObservation', () => { }); }); +describe('Fantawild.getStableRoster', () => { + // Verify the never-shrink-within-TTL roster cache, including merge-by-id, + // fresh-overwriting-cached, and the sustained-shrinkage TTL-expiry path. + let Fantawild: typeof import('../fantawild.js').Fantawild; + // Use parkIds well outside the real chain to avoid colliding with any + // residual cache entries from earlier integration runs. + const PA = 9_100_001, PB = 9_100_002, PC = 9_100_003, PD = 9_100_004, PE = 9_100_005; + + beforeAll(async () => { + const mod = await import('../fantawild.js'); + Fantawild = mod.Fantawild; + }); + + // Build a Fantawild instance with `getItems` stubbed to return a fixed + // payload, so we can control "fresh" without touching the network. + function makeDest(freshItems: any[]): import('../fantawild.js').Fantawild { + const d = new Fantawild({config: { + baseUrl: 'https://image.fangte.com', + apiBaseUrl: 'https://leyou.fangte.com', + }}); + (d as unknown as {getItems: () => Promise}).getItems = async () => freshItems; + return d; + } + + test('returns fresh items when cache is empty (first call)', async () => { + const d = makeDest([{id: 1, itemName: 'A'}, {id: 2, itemName: 'B'}]); + const r = await (d as any).getStableRoster(PA, 'Asia/Shanghai'); + expect(r.map((i: any) => i.id).sort()).toEqual([1, 2]); + }); + + test('NEVER SHRINKS within TTL — fresh=[2 items], cached=[30 items] returns 30', async () => { + // Seed with a 4-item roster + const seed = makeDest([{id: 1}, {id: 2}, {id: 3}, {id: 4}]); + await (seed as any).getStableRoster(PB, 'Asia/Shanghai'); + // Now simulate the API returning only 1 of those (overnight prune) + const pruned = makeDest([{id: 1, itemName: 'pruned'}]); + const r = await (pruned as any).getStableRoster(PB, 'Asia/Shanghai'); + expect(r.map((i: any) => i.id).sort()).toEqual([1, 2, 3, 4]); + }); + + test('GROWS — fresh adds new id not in cache, surface immediately', async () => { + const seed = makeDest([{id: 10}, {id: 11}]); + await (seed as any).getStableRoster(PC, 'Asia/Shanghai'); + const grown = makeDest([{id: 10}, {id: 11}, {id: 12, itemName: 'new ride'}]); + const r = await (grown as any).getStableRoster(PC, 'Asia/Shanghai'); + expect(r.map((i: any) => i.id).sort()).toEqual([10, 11, 12]); + // Confirm the new entry is the fresh one (with itemName). + expect(r.find((i: any) => i.id === 12)?.itemName).toBe('new ride'); + }); + + test('FRESH OVERWRITES CACHED on matching id (name/coord updates surface)', async () => { + const seed = makeDest([{id: 20, itemName: 'Old Name', waitTime: 0}]); + await (seed as any).getStableRoster(PD, 'Asia/Shanghai'); + const renamed = makeDest([{id: 20, itemName: 'New Name', waitTime: 5}]); + const r = await (renamed as any).getStableRoster(PD, 'Asia/Shanghai'); + expect(r).toHaveLength(1); + expect(r[0].itemName).toBe('New Name'); + expect(r[0].waitTime).toBe(5); + }); + + test('EMPTY FRESH preserves cache — handles slow-API timeouts and closed-today parks', async () => { + const seed = makeDest([{id: 30}, {id: 31}, {id: 32}]); + await (seed as any).getStableRoster(PE, 'Asia/Shanghai'); + // Simulate getItems catching a timeout and returning [] + const dropped = makeDest([]); + const r = await (dropped as any).getStableRoster(PE, 'Asia/Shanghai'); + expect(r.map((i: any) => i.id).sort()).toEqual([30, 31, 32]); + }); +}); + diff --git a/src/parks/fantawild/fantawild.ts b/src/parks/fantawild/fantawild.ts index 881022e9..b5fe1523 100644 --- a/src/parks/fantawild/fantawild.ts +++ b/src/parks/fantawild/fantawild.ts @@ -455,6 +455,56 @@ export class Fantawild extends Destination { * 60s if the result is empty — a transient CDN/parse failure shouldn't * stick around as a fabricated zero-day schedule for hours. */ + /** + * Stable per-park roster — the entity list that survives upstream roster + * shrinkage. Merges fresh + cached by `id`; persists the merged result + * for 7 days, but only when the merged size is at least as large as the + * previously-cached size. The "never shrink within TTL" guarantee + * defends against three real failure modes observed over a 25-hour + * probe campaign: + * + * 1. Fantawild's overnight CMS prune (00:00-09:00 China time) where + * ~20 parks lose between 1 ride and their entire roster. + * 2. Slow-API timeouts that fabricate empty rosters from + * `getItems` returning `[]` on catch. + * 3. Scheduled day-closures (`activated:false` in BusinessTime) where + * a park returns 0 items for the whole day. + * + * 7-day TTL chosen so multi-day weekly closures (Chinese parks + * commonly close Mon-Wed during low season) stay protected — a 24h + * TTL would expire and let the empty roster win on day 2. + * + * After 7 days of sustained shrinkage the cache entry expires and the + * smaller roster becomes the new baseline — so legitimately deleted + * rides eventually clear from the wiki. New rides surface immediately + * via the merge-on-id (no need to wait for cache expiry). + * + * Per `feedback_cache_only_true.md`: only ever writes the at-least- + * as-large value, never the shrunken one. + */ + async getStableRoster(parkId: number, timezone: string): Promise { + const fresh = await this.getItems(parkId, timezone); + const cacheKey = `${this.getCacheKeyPrefix()}:roster:v1:${parkId}`; + const cached = (CacheLib.get(cacheKey) ?? []) as FantawildItem[]; + + // Merge by id: new items in fresh are always added; cached items kept + // even when fresh omits them. Fresh values overwrite cached on + // matching id so name / coordinate updates surface immediately. + const byId = new Map(); + for (const c of cached) byId.set(c.id, c); + for (const f of fresh) byId.set(f.id, f); + const merged = [...byId.values()]; + + // "Never shrink": persist only when merged >= cached. A sustained + // reduction past the 7-day TTL eventually lets the empty cache + // expire and the next call establishes the smaller roster as the + // new baseline. + if (merged.length >= cached.length) { + CacheLib.set(cacheKey, merged, 60 * 60 * 24 * 7); + } + return merged; + } + @cache({ callback: (result: ScheduleEntry[]) => result.length === 0 ? 60 : 60 * 60 * 6, cacheVersion: 1, @@ -575,7 +625,10 @@ export class Fantawild extends Destination { location: park.location, } as Entity); - const items = await this.getItems(park.parkId, park.timezone); + // Use the stable roster, not the raw fresh items. This protects the + // entity list from upstream roster shrinkage (overnight CMS prune, + // slow-API timeouts, scheduled day-closures) — see getStableRoster. + const items = await this.getStableRoster(park.parkId, park.timezone); for (const item of items) { if (!item.id) continue; const cleanName = stripFantawildStars(item.itemName || ''); @@ -611,19 +664,39 @@ export class Fantawild extends Destination { @reusable() protected async buildLiveData(): Promise { const perPark = await Promise.all(FANTAWILD_PARKS.map(async park => { - // Fetch items + schedule in parallel for this park, then cross-check. - const [items, schedule] = await Promise.all([ + // Fetch fresh items + stable roster + schedule in parallel. + // - fresh: drives status mapping for rides the API knows about + // right now (waitTime, itemOpened, statusStr). + // - stable roster: the entity list `buildEntityList` emits; rides + // in here but missing from `fresh` get CLOSED so the wiki sees + // a consistent ride count + statuses even during overnight + // pruning. + const [fresh, roster, schedule] = await Promise.all([ this.getItems(park.parkId, park.timezone), + this.getStableRoster(park.parkId, park.timezone), this.getSchedule(park.parkId, park.timezone), ]); const parkOpen = this.parkIsOpenNow(schedule, park.timezone); // Pick up runtime evidence that this park does broadcast live waits, // OR'd with the curated static flag. New parks light up automatically. const liveWaitsOn = park.hasLiveWaitTimes - || await this.recordLiveWaitObservation(park.parkId, items); + || await this.recordLiveWaitObservation(park.parkId, fresh); + const freshById = new Map(fresh.filter(f => f.id).map(f => [f.id, f])); const out: LiveData[] = []; - for (const item of items) { - if (!item.id) continue; + for (const rosterItem of roster) { + if (!rosterItem.id) continue; + const item = freshById.get(rosterItem.id); + // Ride is in the stable roster but missing from this tick's fresh + // response → upstream is pruning it (overnight / closed-today / + // timeout). Emit CLOSED so the wiki keeps the entity but reflects + // that it's not running right now. + if (!item) { + out.push({ + id: this.attractionIdFor(park.parkId, rosterItem.id), + status: 'CLOSED', + } as LiveData); + continue; + } const isOpen = item.itemOpened === true; // `项目维护` ("under maintenance") explicitly flags a planned closure // distinct from "closed because the park's closed" — surface as REFURBISHMENT.