Skip to content

Commit a5d4d8f

Browse files
cubehouseclaude
andauthored
feat(enchantedparks): bundle attraction locations for WoF + MA (#211)
* feat(enchantedparks): bundle attraction locations for WoF + MA The EP WordPress source does not expose per-attraction lat/lng. Without a location field on each emitted attraction, the collector proposes deleting all existing coordinates on every tick — an infinite-rejection loop on the moderation queue for the just-migrated Worlds of Fun and Michigan's Adventure (57 + 54 attractions each). Add a static JSON snapshot of the existing per-ride coordinates (taken from the wiki right after the SixFlags-era data was preserved through the migration) and wire it into the EnchantedParks base class. Subclasses opt in by assigning `this.attractionLocations = locations`. Name lookup folds curly apostrophe (’ U+2019) to straight (') and lowercases — the WP source uses curly and the wiki snapshot uses straight, which would otherwise lose ~20% of matches. Verified: - WoF: 57/57 attractions emit location - MA: 52/54 attractions emit location (2 unmatched are area names "Family Lagoon" / "Funland Farms" not present in the wiki snapshot) - Vitest: 1187/1187 pass Future attractions added by EP later won't have coordinates until they are placed manually via the moderation queue, but that's a one-time touch per ride rather than an every-tick treadmill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(enchantedparks): cover attractionLocations name normalization Added unit tests for the new lookup path that drives whether an emitted attraction retains its existing coordinates: - curly ’ (U+2019) vs straight ' apostrophe match - case-insensitive matching - missing-name returns undefined - no-snapshot returns undefined Full suite 1191/1191 pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 32a4e6f commit a5d4d8f

6 files changed

Lines changed: 556 additions & 5 deletions

File tree

src/parks/enchantedparks/__tests__/enchantedparks.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {describe, test, expect} from 'vitest';
2-
import {parseTribeEvents, type TribeEventsResponse} from '../enchantedparks.js';
2+
import {parseTribeEvents, type TribeEventsResponse, EnchantedParks} from '../enchantedparks.js';
33
import {parseICalFeed} from '../enchantedparks.js';
44
import {parseAttractionsPage} from '../enchantedparks.js';
55

@@ -262,3 +262,55 @@ describe('parseAttractionsPage', () => {
262262
expect(out).toEqual([{slug: 'renegade', name: 'Renegade'}]);
263263
});
264264
});
265+
266+
describe('attraction location lookup', () => {
267+
// Expose protected `lookupAttractionLocation` for direct testing without
268+
// requiring a full destination lifecycle.
269+
class Probe extends EnchantedParks {
270+
public withLocations(
271+
m: Record<string, {latitude: number; longitude: number}>,
272+
): this {
273+
this.attractionLocations = m;
274+
return this;
275+
}
276+
public lookup(name: string) {
277+
return this.lookupAttractionLocation(name);
278+
}
279+
}
280+
281+
const sample = {
282+
"Snoopy's Junction": {latitude: 39.172367, longitude: -94.488782},
283+
'Timber Wolf': {latitude: 39.173334, longitude: -94.488856},
284+
'TIMBERTOWN RAILWAY': {latitude: 43.342000, longitude: -86.275000},
285+
};
286+
287+
test('matches when WP source uses curly apostrophe and snapshot uses straight', () => {
288+
const p = new Probe({}).withLocations(sample);
289+
// Wiki snapshot has "Snoopy's Junction" (straight ').
290+
// WP source emits "Snoopy’s Junction" (curly ’).
291+
expect(p.lookup('Snoopy’s Junction')).toEqual({
292+
latitude: 39.172367, longitude: -94.488782,
293+
});
294+
});
295+
296+
test('matches case-insensitively', () => {
297+
const p = new Probe({}).withLocations(sample);
298+
expect(p.lookup('timber wolf')).toEqual({
299+
latitude: 39.173334, longitude: -94.488856,
300+
});
301+
// Lookup name uppercase, snapshot key uppercase — still matches.
302+
expect(p.lookup('Timbertown Railway')).toEqual({
303+
latitude: 43.342000, longitude: -86.275000,
304+
});
305+
});
306+
307+
test('returns undefined when the name is not in the snapshot', () => {
308+
const p = new Probe({}).withLocations(sample);
309+
expect(p.lookup('Definitely Not A Real Ride')).toBeUndefined();
310+
});
311+
312+
test('returns undefined when no snapshot is configured', () => {
313+
const p = new Probe({});
314+
expect(p.lookup('Timber Wolf')).toBeUndefined();
315+
});
316+
});

src/parks/enchantedparks/enchantedparks.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,47 @@ class EnchantedParks extends Destination {
173173
waterPark?: ParkConfig;
174174
/** Destination-level geographic location (lat/lng) */
175175
destinationLocation?: {latitude: number; longitude: number};
176+
177+
/**
178+
* Optional snapshot of per-attraction coordinates, keyed by attraction
179+
* name. The EP WordPress source does not expose ride-level lat/lng, so
180+
* without this every collector tick proposes deleting the existing
181+
* location data on each attraction. Subclasses load a static JSON
182+
* snapshot from `locations/<slug>.json` taken when the park was migrated.
183+
* Attractions added by EP later will land without coordinates and need
184+
* the moderation queue.
185+
*/
186+
protected attractionLocations?: Record<string, {latitude: number; longitude: number}>;
187+
188+
/**
189+
* Lookup table for attractionLocations, keyed by normalized name. Lazily
190+
* built on first use so subclasses can assign `attractionLocations` in
191+
* the constructor without ordering concerns.
192+
*/
193+
private normalizedLocations?: Map<string, {latitude: number; longitude: number}>;
194+
195+
/**
196+
* Normalize ride names for cross-source matching. The WP source uses
197+
* curly apostrophes (’ U+2019) while the wiki snapshot stores straight
198+
* ones — without folding both to the same form we lose ~20% of matches.
199+
*/
200+
private normalizeName(name: string): string {
201+
return name.replace(/[]/g, "'").toLowerCase();
202+
}
203+
204+
protected lookupAttractionLocation(
205+
rideName: string,
206+
): {latitude: number; longitude: number} | undefined {
207+
if (!this.attractionLocations) return undefined;
208+
if (!this.normalizedLocations) {
209+
this.normalizedLocations = new Map(
210+
Object.entries(this.attractionLocations).map(
211+
([k, v]) => [this.normalizeName(k), v] as const,
212+
),
213+
);
214+
}
215+
return this.normalizedLocations.get(this.normalizeName(rideName));
216+
}
176217
/** IANA timezone for the destination */
177218
@config timezone: string = 'America/Chicago';
178219

@@ -345,15 +386,18 @@ class EnchantedParks extends Destination {
345386
}
346387
parks.push(wpEntity);
347388
for (const r of wpRides) {
348-
attractions.push({
389+
const entity: Entity = {
349390
id: `enchantedparks_attraction_${this.waterPark.code}_${r.slug}`,
350391
name: r.name,
351392
entityType: 'ATTRACTION',
352393
parentId: this.waterPark.id,
353394
parkId: this.waterPark.id,
354395
destinationId: this.destinationId,
355396
timezone: this.timezone,
356-
} as Entity);
397+
} as Entity;
398+
const loc = this.lookupAttractionLocation(r.name);
399+
if (loc) (entity as any).location = loc;
400+
attractions.push(entity);
357401
}
358402
}
359403

@@ -376,15 +420,18 @@ class EnchantedParks extends Destination {
376420
// ones. Skip slugs already claimed by the waterpark page so they don't
377421
// double-emit.
378422
if (waterParkSlugs.has(r.slug)) continue;
379-
attractions.push({
423+
const entity: Entity = {
380424
id: `enchantedparks_attraction_${this.themePark.code}_${r.slug}`,
381425
name: r.name,
382426
entityType: 'ATTRACTION',
383427
parentId: this.themePark.id,
384428
parkId: this.themePark.id,
385429
destinationId: this.destinationId,
386430
timezone: this.timezone,
387-
} as Entity);
431+
} as Entity;
432+
const loc = this.lookupAttractionLocation(r.name);
433+
if (loc) (entity as any).location = loc;
434+
attractions.push(entity);
388435
}
389436
}
390437

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
{
2+
"Winky the Whale": {
3+
"latitude": 43.342773,
4+
"longitude": -86.280718
5+
},
6+
"Thunderbolt": {
7+
"latitude": 43.341675,
8+
"longitude": -86.277131
9+
},
10+
"Sea Dragon": {
11+
"latitude": 43.342564,
12+
"longitude": -86.27798
13+
},
14+
"Beagle Scout Lookout": {
15+
"latitude": 43.343597,
16+
"longitude": -86.280306
17+
},
18+
"Shivering Timbers": {
19+
"latitude": 43.342224,
20+
"longitude": -86.277034
21+
},
22+
"Drummer Boy": {
23+
"latitude": 43.342201,
24+
"longitude": -86.279457
25+
},
26+
"Airplanes": {
27+
"latitude": 43.342281,
28+
"longitude": -86.279455
29+
},
30+
"Carousel": {
31+
"latitude": 43.343565,
32+
"longitude": -86.279765
33+
},
34+
"Corkscrew": {
35+
"latitude": 43.34301,
36+
"longitude": -86.279631
37+
},
38+
"Speed Splashers": {
39+
"latitude": 43.342513,
40+
"longitude": -86.280644
41+
},
42+
"Zach's Zoomer": {
43+
"latitude": 43.342938,
44+
"longitude": -86.280651
45+
},
46+
"Ripcord": {
47+
"latitude": 43.345541,
48+
"longitude": -86.278429
49+
},
50+
"Trabant": {
51+
"latitude": 43.341882,
52+
"longitude": -86.277996
53+
},
54+
"Mad Mouse": {
55+
"latitude": 43.341757,
56+
"longitude": -86.279171
57+
},
58+
"Thunderhawk": {
59+
"latitude": 43.345384,
60+
"longitude": -86.27728
61+
},
62+
"Tilt-A-Whirl": {
63+
"latitude": 43.341822,
64+
"longitude": -86.27771
65+
},
66+
"Timbertown Railway": {
67+
"latitude": 43.342706,
68+
"longitude": -86.276606
69+
},
70+
"Pigpen's Mud Buggies": {
71+
"latitude": 43.343341,
72+
"longitude": -86.280302
73+
},
74+
"Motorcycles": {
75+
"latitude": 43.342732,
76+
"longitude": -86.280518
77+
},
78+
"Scrambler": {
79+
"latitude": 43.341889,
80+
"longitude": -86.277053
81+
},
82+
"Elephants": {
83+
"latitude": 43.342436,
84+
"longitude": -86.279371
85+
},
86+
"Giant Gondola Wheel": {
87+
"latitude": 43.342019,
88+
"longitude": -86.279445
89+
},
90+
"Woodstock Express": {
91+
"latitude": 43.343539,
92+
"longitude": -86.2804
93+
},
94+
"Dodgem": {
95+
"latitude": 43.342396,
96+
"longitude": -86.279114
97+
},
98+
"Frog Hopper": {
99+
"latitude": 43.341773,
100+
"longitude": -86.27933
101+
},
102+
"Wolverine Wildcat": {
103+
"latitude": 43.342652,
104+
"longitude": -86.277388
105+
},
106+
"Kiddie Cars": {
107+
"latitude": 43.3428,
108+
"longitude": -86.280488
109+
},
110+
"Lakeside Gliders": {
111+
"latitude": 43.342605,
112+
"longitude": -86.278948
113+
},
114+
"Flying Trapeze": {
115+
"latitude": 43.341704,
116+
"longitude": -86.277788
117+
},
118+
"Camp Bus": {
119+
"latitude": 43.343789,
120+
"longitude": -86.280262
121+
},
122+
"Boogie Beach": {
123+
"latitude": 43.345329,
124+
"longitude": -86.281135
125+
},
126+
"HydroBlaster": {
127+
"latitude": 43.345608,
128+
"longitude": -86.278838
129+
},
130+
"Funnel of Fear": {
131+
"latitude": 43.345487,
132+
"longitude": -86.279475
133+
},
134+
"PEANUTS™ Trailblazers": {
135+
"latitude": 43.343129,
136+
"longitude": -86.280398
137+
},
138+
"Timbertown Railway Station #2": {
139+
"latitude": 43.34488,
140+
"longitude": -86.27638
141+
},
142+
"Ridge Rider & Wild Slide": {
143+
"latitude": 43.346557,
144+
"longitude": -86.278725
145+
},
146+
"Slidewinders": {
147+
"latitude": 43.345435,
148+
"longitude": -86.280595
149+
},
150+
"Tidal Wave": {
151+
"latitude": 43.345972,
152+
"longitude": -86.279747
153+
},
154+
"Adventure Falls": {
155+
"latitude": 43.345524,
156+
"longitude": -86.277304
157+
},
158+
"Grand Rapids": {
159+
"latitude": 43.34553,
160+
"longitude": -86.276354
161+
},
162+
"Logger's Run": {
163+
"latitude": 43.342433,
164+
"longitude": -86.277639
165+
},
166+
"Swan Boats": {
167+
"latitude": 43.345437,
168+
"longitude": -86.278775
169+
},
170+
"Commotion Ocean": {
171+
"latitude": 43.344728,
172+
"longitude": -86.280463
173+
},
174+
"Half-Pint Paradise": {
175+
"latitude": 43.345403,
176+
"longitude": -86.280094
177+
},
178+
"Paradise Plunge & Tropical Twist": {
179+
"latitude": 43.344842,
180+
"longitude": -86.2799
181+
},
182+
"Beach Party": {
183+
"latitude": 43.344748,
184+
"longitude": -86.280298
185+
},
186+
"Cyclone Zone": {
187+
"latitude": 43.346623,
188+
"longitude": -86.278735
189+
},
190+
"Lazy River": {
191+
"latitude": 43.346108,
192+
"longitude": -86.27945
193+
},
194+
"Mammoth River": {
195+
"latitude": 43.346014,
196+
"longitude": -86.280555
197+
},
198+
"Mine Shaft": {
199+
"latitude": 43.345956,
200+
"longitude": -86.28073
201+
},
202+
"Snake Pit": {
203+
"latitude": 43.344853,
204+
"longitude": -86.281038
205+
},
206+
"Rocky Point Mini-Golf": {
207+
"latitude": 43.343669,
208+
"longitude": -86.279123
209+
},
210+
"Lagoon": {
211+
"latitude": 43.34521,
212+
"longitude": -86.280811
213+
},
214+
"Beagle Scout Acres": {
215+
"latitude": 43.343195,
216+
"longitude": -86.279981
217+
}
218+
}

0 commit comments

Comments
 (0)