Skip to content

feat(fantawild): add base class + Wuhu Dreamland (first chain park)#227

Open
cubehouse wants to merge 9 commits into
mainfrom
feat/fantawild-base
Open

feat(fantawild): add base class + Wuhu Dreamland (first chain park)#227
cubehouse wants to merge 9 commits into
mainfrom
feat/fantawild-base

Conversation

@cubehouse

Copy link
Copy Markdown
Member

Summary

  • New Fantawild base class (src/parks/fantawild/fantawild.ts) for the ~50-park 方特 (Fang Te) Chinese chain
  • First subclass: Wuhu Fantawild Dreamland (parkId 19, the original Dreamland, opened 2007)
  • Full Phase-1 (entity list + schedules) and Phase-2 (live wait times) wired up

How

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:

Source Path Used for
CDN image.fangte.com /UploadFiles/Launch/CityPark/<v>.json Master park list (50 parks)
CDN image.fangte.com /UploadFiles/Launch/BusinessTime/<v>/<parkId>.json Per-park opening hours, ~7-10 days ahead
API leyou.fangte.com /project/api/ParkItem/GetItemBusinessList?parkId=… Ride list + live waitTime + itemOpened + statusStr + coords + show times

statusStr containing 维护 ("maintenance") maps to REFURBISHMENT; itemOpened: false maps to CLOSED; otherwise OPERATING. waitTime is only surfaced when the ride is OPERATING (a closed ride's waitTime: 0 is not a real measurement).

SHOW vs ATTRACTION is inferred from showTimeList shape: a single HH:MM-HH:MM range = operating hours (ATTRACTION); a list of discrete HH:MM entries = showtimes (SHOW). Live-performance / parade tags (真人表演, 巡游) force SHOW.

Trailing star-rating glyphs (孟姜女⭐⭐⭐⭐孟姜女) are stripped from entity names.

Verified

  • npm test — 1253/1253 passing
  • npm run dev -- wuhudreamland — DESTINATION 1, PARK 1, ATTRACTION 22, SHOW 5; 27 live records (26 OPERATING + 1 REFURBISHMENT); 10 days of schedule
  • All HTTP probes against real CDN + API

Test plan

  • CI passes
  • Copilot review comments addressed if any
  • Confirm wait-time field populates during HK/CN operating hours (after merge, watch first daytime tick)

Follow-ups (not in this PR)

  • Bulk-add the remaining ~13 chain parks with verified live data (Adventure Shenyang/Tai'an/Datong/Jiayuguan, Oriental Heritage Jingzhou/Taiyuan/Mianyang, Red Heritage Huai'an/Jining, Boonie Bears Linhai/Ningbo, etc.)
  • Decide whether parks with only static schedules (no live wait integration on Fantawild's side) should ship as schedule-only destinations

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>
Copilot AI review requested due to automatic review settings June 20, 2026 21:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Fantawild base class with CDN schedule scraping and live item/wait-time ingestion from the Fantawild API.
  • Adds first concrete destination WuhuDreamland registered 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 thread src/parks/fantawild/fantawild.ts Outdated
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 thread src/parks/fantawild/fantawild.ts Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

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`);
}
}
Comment thread src/parks/fantawild/fantawild.ts Outdated
* 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/parks/fantawild/fantawild.ts Outdated
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),
});
}
Comment thread src/parks/fantawild/fantawild.ts Outdated
* `@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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/parks/fantawild/fantawild.ts Outdated
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;
}
cubehouse and others added 4 commits June 21, 2026 06:39
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants