33 * @license  Apache-2.0 
44 */ 
55
6- // Data from tricks.json, embedded directly as constants 
7- const  DIRECTIONS  =  [ "front" ,  "back" ] ; 
8- const  STANCES  =  [ "open" ,  "closed" ] ; 
9- const  MOVES  =  [ 
10-   "predator" ,  "predator one" ,  "parallel" ,  "tree" ,  "gazelle" ,  "gazelle s" , 
11-   "lion" ,  "lion s" ,  "toe press" ,  "heel press" ,  "toe roll" ,  "heel roll" , 
12-   "360" ,  "180" ,  "540" ,  "parallel slide" ,  "soul slide" ,  "acid slide" , 
13-   "mizu slide" ,  "star slide" ,  "fast slide" ,  "back slide" ,  "stunami" , 
14-   "ufo swivel" ,  "toe pivot" ,  "heel pivot" 
15- ] ; 
6+ // Data from tricks.json, embedded directly as a constant 
7+ const  TRICK_DATA  =  { 
8+   "DIRECTIONS" : [ "front" ,  "back" ] , 
9+   "STANCES" : [ "open" ,  "closed" ] , 
10+   "MOVES" : [ 
11+     "predator" ,  "predator one" ,  "parallel" ,  "tree" ,  "gazelle" ,  "gazelle s" , 
12+     "lion" ,  "lion s" ,  "toe press" ,  "heel press" ,  "toe roll" ,  "heel roll" , 
13+     "360" ,  "180" ,  "540" ,  "parallel slide" ,  "soul slide" ,  "acid slide" , 
14+     "mizu slide" ,  "star slide" ,  "fast slide" ,  "back slide" ,  "stunami" , 
15+     "ufo swivel" ,  "toe pivot" ,  "heel pivot" 
16+   ] , 
17+   "RULES" : { 
18+     "ONLY_FIRST" : [ "predator" ,  "predator one" ,  "parallel" ] , 
19+     "USE_FAKIE" : [ 
20+       "toe press" ,  "toe roll" ,  "heel press" ,  "heel roll" ,  "360" ,  "180" ,  "540" , 
21+       "parallel slide" ,  "soul slide" ,  "acid slide" ,  "mizu slide" ,  "star slide" , 
22+       "fast slide" ,  "back slide" 
23+     ] , 
24+     "EXCLUDE_STANCE_BASE" : [ "predator" ,  "predator one" ] , 
25+     "ROTATING_MOVES" : [ "gazelle" ,  "lion" ,  "180" ,  "540" ,  "stunami" ,  "ufo swivel" ] 
26+   } 
27+ } ; 
28+ 
29+ // Data from skater_profiles.json, embedded directly as a constant 
30+ const  SKATER_PROFILES  =  [ { 
31+   "name" : "Flow Purist" , 
32+   "traits" : [ "Process" ,  "Intrinsic" ,  "Adaptive" ,  "Solo" ,  "Risk-averse" ,  "Flow" ,  "Subtle" ,  "Flatland" ] 
33+ } ,  { 
34+   "name" : "Spot Hunter" , 
35+   "traits" : [ "Process" ,  "Intrinsic" ,  "Adaptive" ,  "Solo" ,  "Flow" ,  "Risk-tolerant" ,  "Physical" ,  "3D" ] 
36+ } ,  { 
37+   "name" : "Precision Builder" , 
38+   "traits" : [ "Process" ,  "Intrinsic" ,  "Solo" ,  "Risk-averse" ,  "Subtle" ,  "Flatland" ,  "Structured" ,  "Trick" ] 
39+ } ,  { 
40+   "name" : "Creative Connector" , 
41+   "traits" : [ "Process" ,  "Intrinsic" ,  "Adaptive" ,  "Flow" ,  "Flatland" ,  "Risk-tolerant" ,  "Physical" ,  "3D" ,  "Social" ] 
42+ } ,  { 
43+   "name" : "Social Flow Rider" , 
44+   "traits" : [ "Process" ,  "Intrinsic" ,  "Adaptive" ,  "Risk-averse" ,  "Flow" ,  "Subtle" ,  "Flatland" ,  "Social" ] 
45+ } ,  { 
46+   "name" : "Performer" , 
47+   "traits" : [ "Process" ,  "Adaptive" ,  "Flow" ,  "Flatland" ,  "Risk-tolerant" ,  "Physical" ,  "3D" ,  "Social" ,  "Extrinsic" ] 
48+ } ,  { 
49+   "name" : "Urban Technician" , 
50+   "traits" : [ "Intrinsic" ,  "Solo" ,  "Subtle" ,  "Risk-tolerant" ,  "3D" ,  "Structured" ,  "Trick" ,  "Result" ] 
51+ } ,  { 
52+   "name" : "Goal Chaser" , 
53+   "traits" : [ "Intrinsic" ,  "Solo" ,  "Risk-averse" ,  "Subtle" ,  "Flatland" ,  "Structured" ,  "Trick" ,  "Result" ] 
54+ } ,  { 
55+   "name" : "Gearhead" , 
56+   "traits" : [ "Process" ,  "Intrinsic" ,  "Risk-averse" ,  "Subtle" ,  "Flatland" ,  "3D" ,  "Structured" ,  "Trick" ,  "Social" ] 
57+ } ,  { 
58+   "name" : "Competitive Stylist" , 
59+   "traits" : [ "Adaptive" ,  "Flow" ,  "Flatland" ,  "Risk-tolerant" ,  "Physical" ,  "3D" ,  "Social" ,  "Extrinsic" ,  "Result" ] 
60+ } ,  ] ; 
61+ 
62+ 
63+ const  DIRECTIONS  =  TRICK_DATA . DIRECTIONS ; 
64+ const  STANCES  =  TRICK_DATA . STANCES ; 
65+ const  MOVES  =  TRICK_DATA . MOVES ; 
1666
1767// Rules converted to Set for efficient lookups, mirroring Python's set usage 
18- /** @type  {Set<string> } */ 
19- const  onlyFirst  =  new  Set ( [ "predator" ,  "predator one" ,  "parallel" ] ) ; 
20- /** @type  {Set<string> } */ 
21- const  useFakie  =  new  Set ( [ 
22-   "toe press" ,  "toe roll" ,  "heel press" ,  "heel roll" ,  "360" ,  "180" ,  "540" , 
23-   "parallel slide" ,  "soul slide" ,  "acid slide" ,  "mizu slide" ,  "star slide" , 
24-   "fast slide" ,  "back slide" 
25- ] ) ; 
26- /** @type  {Set<string> } */ 
27- const  excludeStanceBase  =  new  Set ( [ "predator" ,  "predator one" ] ) ; 
28- /** @type  {Set<string> } */ 
29- const  rotatingMoves  =  new  Set ( [ "gazelle" ,  "lion" ,  "180" ,  "540" ,  "stunami" ,  "ufo swivel" ] ) ; 
30- 
31- 
32- // Derived rules, mirroring Python's `exclude_stance` and `SUBSEQUENT_MOVES` 
33- /** 
34-  * A set of moves that exclude an automatically determined stance. 
35-  * This is the union of EXCLUDE_STANCE_BASE and USE_FAKIE from the JSON rules, 
36-  * directly translating Python's `exclude_stance_base.union(use_fakie)`. 
37-  * @type  {Set<string> } 
38-  */ 
39- const  excludeStance  =  new  Set ( [ ...excludeStanceBase ,  ...useFakie ] ) ; 
68+ const  onlyFirst  =  new  Set ( TRICK_DATA . RULES . ONLY_FIRST ) ; 
69+ const  useFakie  =  new  Set ( TRICK_DATA . RULES . USE_FAKIE ) ; 
70+ const  rotatingMoves  =  new  Set ( TRICK_DATA . RULES . ROTATING_MOVES ) ; 
71+ const  excludeStanceBase  =  new  Set ( TRICK_DATA . RULES . EXCLUDE_STANCE_BASE ) ; 
4072
41- /** 
42-  * An array of moves that are valid for subsequent tricks (i.e., not "ONLY_FIRST"). 
43-  * This is pre-calculated for efficiency, directly translating Python's `set(MOVES) - only_first`. 
44-  * @type  {string[] } 
45-  */ 
73+ // Derived rules, mirroring Python's logic 
74+ const  excludeStance  =  new  Set ( [ ...excludeStanceBase ,  ...useFakie ] ) ; 
4675const  subsequentMoves  =  MOVES . filter ( move  =>  ! onlyFirst . has ( move ) ) ; 
4776
4877
4978/** 
5079 * @typedef  {'front' | 'back' } Direction 
5180 * @typedef  {'open' | 'closed' } Stance 
5281 * @typedef  {string } Move 
82+  * @typedef  {object } TrickObject 
83+  * @property  {Direction | null } direction 
84+  * @property  {Stance | null } stance 
85+  * @property  {Move | null } move 
86+  * @property  {Direction | null } enterIntoTrick 
87+  * @property  {Direction | null } exitFromTrick 
88+  * @property  {string } name 
89+  * @typedef  {object } SkaterProfile 
90+  * @property  {string } name 
91+  * @property  {string[] } traits 
92+  * @typedef  {object } ProfileMatch 
93+  * @property  {string } name 
94+  * @property  {number } score 
95+  * @property  {string[] } matched_traits 
96+  * @typedef  {object } ClarifyingQuestion 
97+  * @property  {string } question 
98+  * @property  {Object<string, string[]> } choices 
5399 */ 
54100
101+ 
55102/** 
56103 * Represents a single trick with its direction, stance, and move. 
57104 * Automatically generates random values for unspecified properties 
58105 * and adjusts properties like exit direction based on the move, 
59-  * directly translating the Python `Trick` dataclass logic, especially its `__post_init__` method . 
106+  * directly translating the Python `Trick` dataclass logic. 
60107 */ 
61108export  class  Trick  { 
62109  /** @type  {Direction | null } */ 
@@ -89,7 +136,7 @@ export class Trick {
89136    enterIntoTrick =  null , 
90137    exitFromTrick =  null , 
91138  }  =  { } )  { 
92-     // 1. Input validation, mirroring Python's `__post_init__` validation  
139+     // 1. Input validation 
93140    if  ( direction  !==  null  &&  ! DIRECTIONS . includes ( direction ) )  { 
94141      throw  new  Error ( `Invalid direction: '${ direction } ${ DIRECTIONS . join ( ', ' ) }  ) ; 
95142    } 
@@ -107,7 +154,7 @@ export class Trick {
107154    this . enterIntoTrick  =  enterIntoTrick ; 
108155    this . exitFromTrick  =  exitFromTrick ; 
109156
110-     // 3. Generate default values if not provided, mirroring Python's `__post_init__`  
157+     // 3. Generate default values if not provided 
111158    if  ( this . direction  ===  null )  { 
112159      this . direction  =  DIRECTIONS [ Math . floor ( Math . random ( )  *  DIRECTIONS . length ) ] ; 
113160    } 
@@ -142,14 +189,12 @@ export class Trick {
142189  /** 
143190   * Returns a human-readable string representation of the trick, 
144191   * directly translating the Python `__str__` method. 
145-    * This handles "fakie" / "forward" display names for relevant moves. 
146192   * @returns  {string } The formatted name of the trick. 
147193   */ 
148194  getName ( )  { 
149195    const  parts  =  [ ] ; 
150196    let  displayDirection  =  this . direction ; 
151197
152-     // Handle fakie/forward display name, mirroring Python's `__str__` 
153198    if  ( this . move  !==  null  &&  useFakie . has ( this . move ) )  { 
154199      if  ( this . direction  ===  "back" )  { 
155200        displayDirection  =  "fakie" ; 
@@ -173,9 +218,8 @@ export class Trick {
173218
174219  /** 
175220   * Returns a plain JavaScript object representation of the trick, 
176-    * including all its properties and its full display name, 
177221   * directly translating the Python `to_dict` method. 
178-    * @returns  {object } An object containing the trick's properties and its name. 
222+    * @returns  {TrickObject } An object containing the trick's properties and its name. 
179223   */ 
180224  toObject ( )  { 
181225    return  { 
@@ -190,46 +234,39 @@ export class Trick {
190234} 
191235
192236/** 
193-  * Generates a combination (combo) of tricks, 
194-  * directly translating the Python `generate_combo` function. 
195-  * 
196-  * @param  {number | null } [numTricks=null] - The number of tricks to generate in the combo. 
197-  *   If null, a random number between 2 and 5 (inclusive) will be chosen, mirroring Python's `random.randint(2, 5)`. 
198-  * @returns  {object[] } A list of trick objects, each with their properties and a 'name' field. 
199-  *   Returns an empty array if numTricks is 0 or less. 
237+  * Generates a combination (combo) of tricks. 
238+  * @param  {number | null } [numTricks=null] - The number of tricks to generate. 
239+  *   If null, a random number between 2 and 5 will be chosen. 
240+  * @returns  {TrickObject[] } An array of trick objects. 
200241 */ 
201242export  function  generateCombo ( numTricks  =  null )  { 
202-   // Mirroring Python's default num_of_tricks = random.randint(2, 5) 
203-   if  ( numTricks  ===  null )  { 
204-     numTricks  =  Math . floor ( Math . random ( )  *  ( 5  -  2  +  1 ) )  +  2 ; 
243+   let   count   =   numTricks ; 
244+   if  ( count  ===  null )  { 
245+     count  =  Math . floor ( Math . random ( )  *  ( 5  -  2  +  1 ) )  +  2 ; 
205246  } 
206247
207-   if  ( numTricks  <=  0 )  { 
248+   if  ( count  <=  0 )  { 
208249    return  [ ] ; 
209250  } 
210251
211-   /** @type  {Trick[] } */ 
212252  const  trickObjects  =  [ ] ; 
213-   /** @type  {Trick | null } */ 
214253  let  previousTrick  =  null ; 
215254
216-   for  ( let  i  =  0 ;  i  <  numTricks ;  i ++ )  { 
217-     /** @type  {Trick } */ 
255+   for  ( let  i  =  0 ;  i  <  count ;  i ++ )  { 
218256    let  newTrick ; 
219257
220258    if  ( i  ===  0 )  { 
221-       // First trick: choose from all moves, mirroring Python's logic  
259+       // First trick: choose from all moves 
222260      const  move  =  MOVES [ Math . floor ( Math . random ( )  *  MOVES . length ) ] ; 
223-       newTrick  =  new  Trick ( {  move } ) ; 
261+       newTrick  =  new  Trick ( { 
262+         move
263+       } ) ; 
224264    }  else  { 
225-       // Subsequent tricks: respect exit direction of previous trick 
226-       // and choose from moves not marked as ONLY_FIRST. 
265+       // Subsequent tricks: respect exit direction and choose from allowed moves 
227266      if  ( ! previousTrick )  { 
228-         // This case should ideally not be reached, mirroring Python's `assert` 
229267        throw  new  Error ( "Previous trick is undefined for subsequent trick generation." ) ; 
230268      } 
231269      const  requiredDirection  =  previousTrick . exitFromTrick ; 
232-       // Choose from the pre-filtered array of subsequent moves for efficiency 
233270      const  move  =  subsequentMoves [ Math . floor ( Math . random ( )  *  subsequentMoves . length ) ] ; 
234271      newTrick  =  new  Trick ( { 
235272        direction : requiredDirection , 
@@ -241,6 +278,118 @@ export function generateCombo(numTricks = null) {
241278    previousTrick  =  newTrick ; 
242279  } 
243280
244-   // Mirroring Python's `[trick.to_dict() for trick in trick_objects]` 
245281  return  trickObjects . map ( trick  =>  trick . toObject ( ) ) ; 
282+ } 
283+ 
284+ /** 
285+  * Finds and ranks skater profiles based on user-provided traits. 
286+  * @param  {string[] } userTraits - A list of strings representing the user's skating style. 
287+  * @returns  {ProfileMatch[] } A list of profile match objects, sorted by score descending. 
288+  */ 
289+ export  function  findProfileMatches ( userTraits )  { 
290+   if  ( ! SKATER_PROFILES  ||  SKATER_PROFILES . length  ===  0 )  { 
291+     return  [ ] ; 
292+   } 
293+ 
294+   const  normalizedUserTraits  =  new  Set ( userTraits . map ( t  =>  t . toLowerCase ( ) ) ) ; 
295+ 
296+   const  matches  =  SKATER_PROFILES . map ( profile  =>  { 
297+     const  profileTraitsLower  =  new  Set ( profile . traits . map ( t  =>  t . toLowerCase ( ) ) ) ; 
298+     const  matchedSet  =  new  Set ( [ ...normalizedUserTraits ] . filter ( trait  =>  profileTraitsLower . has ( trait ) ) ) ; 
299+     const  score  =  matchedSet . size ; 
300+ 
301+     return  { 
302+       name : profile . name , 
303+       score : score , 
304+       matched_traits : Array . from ( matchedSet ) . sort ( ) , 
305+     } ; 
306+   } ) ; 
307+ 
308+   return  matches . sort ( ( a ,  b )  =>  b . score  -  a . score ) ; 
309+ } 
310+ 
311+ /** 
312+  * Finds a single skater profile by its name (case-insensitive). 
313+  * @param  {string } name - The name of the profile to find. 
314+  * @returns  {SkaterProfile | undefined } The profile object if found, otherwise undefined. 
315+  */ 
316+ export  function  getProfileByName ( name )  { 
317+   return  SKATER_PROFILES . find ( profile  =>  profile . name . toLowerCase ( )  ===  name . toLowerCase ( ) ) ; 
318+ } 
319+ 
320+ /** 
321+  * Gets a unique, sorted list of all traits available across all profiles. 
322+  * @returns  {string[] } A sorted list of unique trait strings. 
323+  */ 
324+ export  function  getAllTraits ( )  { 
325+   const  allTraits  =  new  Set ( SKATER_PROFILES . flatMap ( p  =>  p . traits ) ) ; 
326+   return  Array . from ( allTraits ) . sort ( ) ; 
327+ } 
328+ 
329+ /** 
330+  * Generates a clarifying question to differentiate between closely matched profiles. 
331+  * @param  {ProfileMatch[] } topProfiles - A list of the top profile matches from `findProfileMatches`. 
332+  * @returns  {ClarifyingQuestion | null } An object with a question and choices, or null if a question cannot be generated. 
333+  */ 
334+ export  function  generateClarifyingQuestion ( topProfiles )  { 
335+   if  ( ! topProfiles  ||  topProfiles . length  <  2 )  { 
336+     return  null ; 
337+   } 
338+ 
339+   const  OPPOSING_TRAITS_MAP  =  { 
340+     "Solo" : "Social" , 
341+     "Social" : "Solo" , 
342+     "Risk-averse" : "Risk-tolerant" , 
343+     "Risk-tolerant" : "Risk-averse" , 
344+     "Flatland" : "3D" , 
345+     "3D" : "Flatland" , 
346+     "Subtle" : "Physical" , 
347+     "Physical" : "Subtle" , 
348+     "Structured" : "Adaptive" , 
349+     "Adaptive" : "Structured" , 
350+     "Process" : "Result" , 
351+     "Result" : "Process" 
352+   } ; 
353+ 
354+   const  profileA_full  =  getProfileByName ( topProfiles [ 0 ] . name ) ; 
355+   const  profileB_full  =  getProfileByName ( topProfiles [ 1 ] . name ) ; 
356+ 
357+   if  ( ! profileA_full  ||  ! profileB_full )  { 
358+     return  null ; 
359+   } 
360+ 
361+   const  traitsA  =  new  Set ( profileA_full . traits ) ; 
362+   const  traitsB  =  new  Set ( profileB_full . traits ) ; 
363+ 
364+   const  uniqueToA  =  new  Set ( [ ...traitsA ] . filter ( x  =>  ! traitsB . has ( x ) ) ) ; 
365+   const  uniqueToB  =  new  Set ( [ ...traitsB ] . filter ( x  =>  ! traitsA . has ( x ) ) ) ; 
366+ 
367+   // 1. Prioritize finding a clear opposing pair 
368+   for  ( const  trait  of  uniqueToA )  { 
369+     const  opposite  =  OPPOSING_TRAITS_MAP [ trait ] ; 
370+     if  ( opposite  &&  uniqueToB . has ( opposite ) )  { 
371+       return  { 
372+         question : `Which is more important to you: '${ trait } ${ opposite }  , 
373+         choices : { 
374+           [ trait ] : [ trait ] , 
375+           [ opposite ] : [ opposite ] , 
376+         } , 
377+       } ; 
378+     } 
379+   } 
380+ 
381+   // 2. If no direct opposites, use the most significant unique traits 
382+   if  ( uniqueToA . size  >  0  &&  uniqueToB . size  >  0 )  { 
383+     const  traitA  =  [ ...uniqueToA ] [ 0 ] ; 
384+     const  traitB  =  [ ...uniqueToB ] [ 0 ] ; 
385+     return  { 
386+       question : `To help narrow it down, which sounds more like you: '${ traitA } ${ traitB }  , 
387+       choices : { 
388+         [ traitA ] : [ traitA ] , 
389+         [ traitB ] : [ traitB ] 
390+       } , 
391+     } ; 
392+   } 
393+ 
394+   return  null ; 
246395} 
0 commit comments