feat(fantawild): add base class + Wuhu Dreamland (first chain park)#227
Open
cubehouse wants to merge 9 commits into
Open
feat(fantawild): add base class + Wuhu Dreamland (first chain park)#227cubehouse wants to merge 9 commits into
cubehouse wants to merge 9 commits into
Conversation
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 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new Fantawild (方特) destination family by introducing a shared base class and registering the first park (Wuhu Fantawild Dreamland), wiring entities, schedules, and live wait times into the ParksAPI Destination framework.
Changes:
- Introduces
Fantawildbase class with CDN schedule scraping and live item/wait-time ingestion from the Fantawild API. - Adds first concrete destination
WuhuDreamlandregistered under the Fantawild category. - Adds unit tests for parsers/classifiers plus a registry/entity-id regression test for the new destination.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/parks/fantawild/wuhudreamland.ts | Registers the first Fantawild destination and provides per-park config (ids, timezone, location). |
| src/parks/fantawild/fantawild.ts | Implements Fantawild base behavior: schedule parsing, entity building, and live-data mapping. |
| src/parks/fantawild/tests/fantawild.test.ts | Adds unit coverage for schedule parsing, name sanitization, and SHOW classification. |
| src/tests/entityIdRegression.test.ts | Ensures the destination is registered and emits the intended DESTINATION entity id. |
Comment on lines
+10
to
+16
| * 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. |
Comment on lines
+329
to
+332
| /** Fetch + return the raw item list. Returns [] on any failure. */ | ||
| async fetchItems(): Promise<FantawildItem[]> { | ||
| try { | ||
| const resp = await this.fetchItemBusinessList(); |
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 <noreply@anthropic.com>
Comment on lines
+157
to
+179
| 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), | ||
| }); | ||
| } | ||
| } |
Comment on lines
+223
to
+242
| protected async _init(): Promise<void> { | ||
| 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`); | ||
| } | ||
| } |
| * fetch/parse failure so an outage on one park doesn't take out a multi- | ||
| * park sweep. | ||
| */ | ||
| @cache({ttlSeconds: 60 * 10}) |
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 <noreply@anthropic.com>
Comment on lines
+262
to
+265
| throw new Error( | ||
| `${this.constructor.name} requires baseUrl to be configured ` + | ||
| `(set FANTAWILD_BASEURL in .env, e.g. https://image.fangte.com)`, | ||
| ); |
Comment on lines
+268
to
+271
| throw new Error( | ||
| `${this.constructor.name} requires apiBaseUrl to be configured ` + | ||
| `(set FANTAWILD_APIBASEURL in .env, e.g. https://leyou.fangte.com)`, | ||
| ); |
…ed 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 <noreply@anthropic.com>
Comment on lines
+198
to
+215
| 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(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(nightCloseDate, ev.nightEndTime, timezone), | ||
| }); | ||
| } |
| * `@http` cache still gives a 15-min upstream-update floor for the rare | ||
| * case (closure pushed at short notice). | ||
| */ | ||
| @cache({ttlSeconds: 60 * 60 * 6}) |
Both Copilot comments on commit bfc9945: - 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 <noreply@anthropic.com>
Comment on lines
+29
to
+33
| 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'; |
Comment on lines
+151
to
+157
| 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; | ||
| return h * 60 + min; | ||
| } |
One of two Copilot comments on commit 9bee65f: 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 <noreply@anthropic.com>
…attern) 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_<itemId>`) — 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 <noreply@anthropic.com>
…st coverage 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fantawildbase class (src/parks/fantawild/fantawild.ts) for the ~50-park 方特 (Fang Te) Chinese chainHow
Two services back the chain's mobile app (
com.hytch.ftthemepark); both turned out to be callable without bearer tokens or HMAC signing despite captured app headers suggesting otherwise:image.fangte.com/UploadFiles/Launch/CityPark/<v>.jsonimage.fangte.com/UploadFiles/Launch/BusinessTime/<v>/<parkId>.jsonleyou.fangte.com/project/api/ParkItem/GetItemBusinessList?parkId=…waitTime+itemOpened+statusStr+ coords + show timesstatusStrcontaining维护("maintenance") maps toREFURBISHMENT;itemOpened: falsemaps toCLOSED; otherwiseOPERATING.waitTimeis only surfaced when the ride is OPERATING (a closed ride'swaitTime: 0is not a real measurement).SHOW vs ATTRACTION is inferred from
showTimeListshape: a singleHH:MM-HH:MMrange = operating hours (ATTRACTION); a list of discreteHH:MMentries = showtimes (SHOW). Live-performance / parade tags (真人表演,巡游) force SHOW.Trailing star-rating glyphs (
孟姜女⭐⭐⭐⭐→孟姜女) are stripped from entity names.Verified
npm test— 1253/1253 passingnpm run dev -- wuhudreamland— DESTINATION 1, PARK 1, ATTRACTION 22, SHOW 5; 27 live records (26 OPERATING + 1 REFURBISHMENT); 10 days of scheduleTest plan
Follow-ups (not in this PR)