feat: suppport skells from quests
1adde03 parent: a6f0a51 modified
src/data/skells.ts +102 -13 | @@ -7,12 +7,15 @@ export type SkellFrameFamily = | ||
| 7 | 7 | | "Lailah" |
| 8 | 8 | | "Inferno" |
| 9 | 9 | | "Mastema" |
| 10 | - | "Amdusias"; | |
| 10 | + | "Amdusias" | |
| 11 | + | "Hraesvelg"; | |
| 11 | 12 | |
| 12 | 13 | export interface SkellFrameFact { |
| 13 | 14 | frame: string; |
| 14 | 15 | requiredLevel: number; |
| 15 | 16 | acquisition: string; |
| 17 | + /** Gating quest or story beat beyond the license; sorts alternatives last. */ | |
| 18 | + prerequisite?: string; | |
| 16 | 19 | } |
| 17 | 20 | |
| 18 | 21 | export const skellSource = |
| @@ -35,6 +38,7 @@ export const shopFrames: Record<30 | 50, Record<SkellFrameFamily, SkellFrameFact | ||
| 35 | 38 | Inferno: { frame: "AS0530 Inferno ST", requiredLevel: 30, acquisition: "Shop purchase" }, |
| 36 | 39 | Mastema: { frame: "XS0630 Mastema ST", requiredLevel: 30, acquisition: "Shop purchase" }, |
| 37 | 40 | Amdusias: { frame: "XS0730 Amdusias ST", requiredLevel: 30, acquisition: "Shop purchase" }, |
| 41 | + Hraesvelg: { frame: "GD0930 Hraesvelg ST", requiredLevel: 30, acquisition: "Shop purchase" }, | |
| 38 | 42 | }, |
| 39 | 43 | 50: { |
| 40 | 44 | Formula: { frame: "AS0150 Formula ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| @@ -44,6 +48,7 @@ export const shopFrames: Record<30 | 50, Record<SkellFrameFamily, SkellFrameFact | ||
| 44 | 48 | Inferno: { frame: "AS0550 Inferno ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| 45 | 49 | Mastema: { frame: "XS0650 Mastema ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| 46 | 50 | Amdusias: { frame: "XS0750 Amdusias ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| 51 | + Hraesvelg: { frame: "GD0950 Hraesvelg ST", requiredLevel: 50, acquisition: "Shop purchase" }, | |
| 47 | 52 | }, |
| 48 | 53 | }; |
| 49 | 54 | |
| @@ -56,24 +61,108 @@ export const postgameFrames: Record<SkellFrameFamily, SkellFrameFact> = { | ||
| 56 | 61 | Inferno: { frame: "Inferno Skydon", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, |
| 57 | 62 | Mastema: { frame: "Mastema White Reaper", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, |
| 58 | 63 | Amdusias: { frame: "Amdusias Hades", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, |
| 64 | + Hraesvelg: { frame: "Hraesvelg Lord", requiredLevel: 60, acquisition: "Craftable following Thinking Makes It So", prerequisite: "Thinking Makes It So" }, | |
| 59 | 65 | }; |
| 60 | 66 | |
| 61 | -/** Fixed family order used to derive deterministic alternative frames. */ | |
| 62 | -export const frameFamilyOrder: readonly SkellFrameFamily[] = [ | |
| 63 | - "Formula", | |
| 64 | - "Urban", | |
| 65 | - "Verus", | |
| 66 | - "Lailah", | |
| 67 | - "Inferno", | |
| 68 | - "Mastema", | |
| 69 | - "Amdusias", | |
| 70 | -]; | |
| 67 | +/** | |
| 68 | + * Quest- or story-gated frames outside the shop families; the engine ranks | |
| 69 | + * them against shop frames by strategy effectiveness, and the acquisition | |
| 70 | + * text tells the player which gate to clear. | |
| 71 | + * The level-60 entries only surface with the postgame-frames toggle. | |
| 72 | + * Chapter-12-gated crafts carry no prerequisite: the toggle already means that. | |
| 73 | + * Excluded: AS0115 Formula XT, a temporary loaner during A Girl's Wings. | |
| 74 | + */ | |
| 75 | +export const specialFrames: Record<20 | 30 | 50 | 60, readonly SkellFrameFact[]> = { | |
| 76 | + 20: [ | |
| 77 | + { frame: "GD0920 Hraesvelg ST", requiredLevel: 20, acquisition: "Complete New Skell on the Block", prerequisite: "New Skell on the Block" }, | |
| 78 | + ], | |
| 79 | + 30: [ | |
| 80 | + { frame: "US0830 Dozer", requiredLevel: 30, acquisition: "Craftable following Bozé's Ignorance", prerequisite: "Bozé's Ignorance" }, | |
| 81 | + { frame: "US0232 Police", requiredLevel: 30, acquisition: "Craftable following Climbing the Ladder", prerequisite: "Climbing the Ladder" }, | |
| 82 | + ], | |
| 83 | + 50: [ | |
| 84 | + { frame: "Ares Prime", requiredLevel: 50, acquisition: "Complete Chapter 13 (Act 1)", prerequisite: "Chapter 13 (Act 1)" }, | |
| 85 | + { frame: "US0850 Excavator", requiredLevel: 50, acquisition: "Craftable following Rapid Misfire", prerequisite: "Rapid Misfire" }, | |
| 86 | + ], | |
| 87 | + 60: [ | |
| 88 | + { frame: "Ares 90", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | |
| 89 | + { frame: "Ares 70", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | |
| 90 | + { frame: "US0860 Excavator", requiredLevel: 60, acquisition: "Craftable following Rapid Misfire", prerequisite: "Rapid Misfire" }, | |
| 91 | + ], | |
| 92 | +}; | |
| 93 | + | |
| 94 | +/** Combat stats driving alternative-frame effectiveness; armor is 0 for every frame. */ | |
| 95 | +export interface SkellFrameStats { | |
| 96 | + hp: number; | |
| 97 | + melee: number; | |
| 98 | + ranged: number; | |
| 99 | + potential: number; | |
| 100 | + evasion: number; | |
| 101 | + fuel: number; | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** Canonical frame stats from the Skell frame table (same source as above). */ | |
| 105 | +export const frameStats: Record<string, SkellFrameStats> = { | |
| 106 | + "US0220 Urban ST": { hp: 5350, melee: 62, ranged: 46, potential: 180, evasion: 48, fuel: 7650 }, | |
| 107 | + "AS0130 Formula ST": { hp: 8200, melee: 62, ranged: 46, potential: 250, evasion: 50, fuel: 8100 }, | |
| 108 | + "US0230 Urban ST": { hp: 9000, melee: 81, ranged: 39, potential: 280, evasion: 48, fuel: 9180 }, | |
| 109 | + "AS0330 Verus ST": { hp: 8500, melee: 70, ranged: 44, potential: 300, evasion: 48, fuel: 8640 }, | |
| 110 | + "AS0430 Lailah ST": { hp: 10500, melee: 92, ranged: 46, potential: 200, evasion: 32, fuel: 12000 }, | |
| 111 | + "AS0530 Inferno ST": { hp: 11000, melee: 74, ranged: 72, potential: 190, evasion: 30, fuel: 13800 }, | |
| 112 | + "XS0630 Mastema ST": { hp: 11800, melee: 85, ranged: 83, potential: 120, evasion: 10, fuel: 18000 }, | |
| 113 | + "XS0730 Amdusias ST": { hp: 13900, melee: 129, ranged: 46, potential: 100, evasion: 10, fuel: 25200 }, | |
| 114 | + "GD0930 Hraesvelg ST": { hp: 7600, melee: 76, ranged: 70, potential: 370, evasion: 77, fuel: 7600 }, | |
| 115 | + "AS0150 Formula ST": { hp: 12400, melee: 98, ranged: 72, potential: 420, evasion: 90, fuel: 8100 }, | |
| 116 | + "US0250 Urban ST": { hp: 13500, melee: 127, ranged: 60, potential: 490, evasion: 88, fuel: 9180 }, | |
| 117 | + "AS0350 Verus ST": { hp: 13000, melee: 110, ranged: 68, potential: 470, evasion: 88, fuel: 8640 }, | |
| 118 | + "AS0450 Lailah ST": { hp: 15500, melee: 145, ranged: 72, potential: 350, evasion: 70, fuel: 12000 }, | |
| 119 | + "AS0550 Inferno ST": { hp: 16000, melee: 116, ranged: 112, potential: 300, evasion: 68, fuel: 13800 }, | |
| 120 | + "XS0650 Mastema ST": { hp: 17000, melee: 133, ranged: 128, potential: 200, evasion: 50, fuel: 18000 }, | |
| 121 | + "XS0750 Amdusias ST": { hp: 19800, melee: 203, ranged: 72, potential: 150, evasion: 48, fuel: 25200 }, | |
| 122 | + "GD0950 Hraesvelg ST": { hp: 11400, melee: 112, ranged: 104, potential: 570, evasion: 118, fuel: 7600 }, | |
| 123 | + "Formula Zero": { hp: 17000, melee: 387, ranged: 381, potential: 650, evasion: 100, fuel: 10560 }, | |
| 124 | + "Urban Lincoln": { hp: 18000, melee: 501, ranged: 318, potential: 700, evasion: 98, fuel: 11280 }, | |
| 125 | + "Verus Cain": { hp: 17500, melee: 433, ranged: 360, potential: 680, evasion: 98, fuel: 10560 }, | |
| 126 | + "Lailah Queen": { hp: 21000, melee: 570, ranged: 381, potential: 550, evasion: 80, fuel: 13920 }, | |
| 127 | + "Inferno Skydon": { hp: 22000, melee: 456, ranged: 593, potential: 470, evasion: 78, fuel: 15600 }, | |
| 128 | + "Mastema White Reaper": { hp: 23500, melee: 524, ranged: 678, potential: 300, evasion: 60, fuel: 20640 }, | |
| 129 | + "Amdusias Hades": { hp: 25500, melee: 798, ranged: 381, potential: 200, evasion: 58, fuel: 28800 }, | |
| 130 | + "Hraesvelg Lord": { hp: 15800, melee: 460, ranged: 448, potential: 860, evasion: 134, fuel: 9800 }, | |
| 131 | + "GD0920 Hraesvelg ST": { hp: 5000, melee: 72, ranged: 66, potential: 290, evasion: 58, fuel: 7600 }, | |
| 132 | + "US0830 Dozer": { hp: 12500, melee: 96, ranged: 92, potential: 200, evasion: 40, fuel: 18000 }, | |
| 133 | + "US0232 Police": { hp: 11000, melee: 105, ranged: 49, potential: 320, evasion: 50, fuel: 11520 }, | |
| 134 | + "Ares Prime": { hp: 20000, melee: 585, ranged: 425, potential: 350, evasion: 110, fuel: 17600 }, | |
| 135 | + "US0850 Excavator": { hp: 22400, melee: 236, ranged: 40, potential: 240, evasion: 26, fuel: 26800 }, | |
| 136 | + "Ares 70": { hp: 32000, melee: 643, ranged: 448, potential: 600, evasion: 100, fuel: 20160 }, | |
| 137 | + "Ares 90": { hp: 41000, melee: 816, ranged: 568, potential: 900, evasion: 120, fuel: 24000 }, | |
| 138 | + "US0860 Excavator": { hp: 33000, melee: 920, ranged: 320, potential: 500, evasion: 78, fuel: 26800 }, | |
| 139 | +}; | |
| 140 | + | |
| 141 | +/** | |
| 142 | + * Heuristic stat weights per strategy for alternative-frame effectiveness; | |
| 143 | + * each stat is normalized by the tier maximum before weighting. | |
| 144 | + */ | |
| 145 | +export const strategyFrameWeights: Record<StrategyKey, Partial<Record<keyof SkellFrameStats, number>>> = { | |
| 146 | + duelist: { melee: 1, hp: 0.4 }, | |
| 147 | + bastion: { hp: 1, fuel: 0.3 }, | |
| 148 | + jaguar: { evasion: 1, potential: 0.6 }, | |
| 149 | + astral: { ranged: 1, hp: 0.3 }, | |
| 150 | + mastermind: { potential: 1, ranged: 0.6 }, | |
| 151 | + galactic: { melee: 1, potential: 0.6 }, | |
| 152 | +}; | |
| 71 | 153 | |
| 72 | -/** Heuristic frame family by tactical strategy; the frame facts above remain canonical. */ | |
| 154 | +/** | |
| 155 | + * Heuristic frame family by tactical strategy; the frame facts above remain | |
| 156 | + * canonical. Guide-informed: game8 crowns Lailah toughest overall, community | |
| 157 | + * meta favors Amdusias melee, Mastema ranged, Verus beam, and — since DE — | |
| 158 | + * Hraesvelg for evasion/potential builds over Formula. | |
| 159 | + * Sources: https://game8.co/games/Xenoblade-Chronicles-X/archives/507490 | |
| 160 | + * https://www.thegamer.com/xenoblade-chronicles-x-definitive-edition-best-skells/ | |
| 161 | + */ | |
| 73 | 162 | export const strategyFrameFamily: Record<StrategyKey, SkellFrameFamily> = { |
| 74 | 163 | duelist: "Amdusias", |
| 75 | 164 | bastion: "Lailah", |
| 76 | - jaguar: "Formula", | |
| 165 | + jaguar: "Hraesvelg", | |
| 77 | 166 | astral: "Mastema", |
| 78 | 167 | mastermind: "Inferno", |
| 79 | 168 | galactic: "Verus", |
| @@ -7,12 +7,15 @@ export type SkellFrameFamily = | |||
| 7 | | "Lailah" | 7 | | "Lailah" |
| 8 | | "Inferno" | 8 | | "Inferno" |
| 9 | | "Mastema" | 9 | | "Mastema" |
| 10 | - | "Amdusias"; | 10 | + | "Amdusias" |
| 11 | + | "Hraesvelg"; | ||
| 11 | 12 | ||
| 12 | export interface SkellFrameFact { | 13 | export interface SkellFrameFact { |
| 13 | frame: string; | 14 | frame: string; |
| 14 | requiredLevel: number; | 15 | requiredLevel: number; |
| 15 | acquisition: string; | 16 | acquisition: string; |
| 17 | + /** Gating quest or story beat beyond the license; sorts alternatives last. */ | ||
| 18 | + prerequisite?: string; | ||
| 16 | } | 19 | } |
| 17 | 20 | ||
| 18 | export const skellSource = | 21 | export const skellSource = |
| @@ -35,6 +38,7 @@ export const shopFrames: Record<30 | 50, Record<SkellFrameFamily, SkellFrameFact | |||
| 35 | Inferno: { frame: "AS0530 Inferno ST", requiredLevel: 30, acquisition: "Shop purchase" }, | 38 | Inferno: { frame: "AS0530 Inferno ST", requiredLevel: 30, acquisition: "Shop purchase" }, |
| 36 | Mastema: { frame: "XS0630 Mastema ST", requiredLevel: 30, acquisition: "Shop purchase" }, | 39 | Mastema: { frame: "XS0630 Mastema ST", requiredLevel: 30, acquisition: "Shop purchase" }, |
| 37 | Amdusias: { frame: "XS0730 Amdusias ST", requiredLevel: 30, acquisition: "Shop purchase" }, | 40 | Amdusias: { frame: "XS0730 Amdusias ST", requiredLevel: 30, acquisition: "Shop purchase" }, |
| 41 | + Hraesvelg: { frame: "GD0930 Hraesvelg ST", requiredLevel: 30, acquisition: "Shop purchase" }, | ||
| 38 | }, | 42 | }, |
| 39 | 50: { | 43 | 50: { |
| 40 | Formula: { frame: "AS0150 Formula ST", requiredLevel: 50, acquisition: "Shop purchase" }, | 44 | Formula: { frame: "AS0150 Formula ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| @@ -44,6 +48,7 @@ export const shopFrames: Record<30 | 50, Record<SkellFrameFamily, SkellFrameFact | |||
| 44 | Inferno: { frame: "AS0550 Inferno ST", requiredLevel: 50, acquisition: "Shop purchase" }, | 48 | Inferno: { frame: "AS0550 Inferno ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| 45 | Mastema: { frame: "XS0650 Mastema ST", requiredLevel: 50, acquisition: "Shop purchase" }, | 49 | Mastema: { frame: "XS0650 Mastema ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| 46 | Amdusias: { frame: "XS0750 Amdusias ST", requiredLevel: 50, acquisition: "Shop purchase" }, | 50 | Amdusias: { frame: "XS0750 Amdusias ST", requiredLevel: 50, acquisition: "Shop purchase" }, |
| 51 | + Hraesvelg: { frame: "GD0950 Hraesvelg ST", requiredLevel: 50, acquisition: "Shop purchase" }, | ||
| 47 | }, | 52 | }, |
| 48 | }; | 53 | }; |
| 49 | 54 | ||
| @@ -56,24 +61,108 @@ export const postgameFrames: Record<SkellFrameFamily, SkellFrameFact> = { | |||
| 56 | Inferno: { frame: "Inferno Skydon", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | 61 | Inferno: { frame: "Inferno Skydon", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, |
| 57 | Mastema: { frame: "Mastema White Reaper", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | 62 | Mastema: { frame: "Mastema White Reaper", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, |
| 58 | Amdusias: { frame: "Amdusias Hades", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | 63 | Amdusias: { frame: "Amdusias Hades", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, |
| 64 | + Hraesvelg: { frame: "Hraesvelg Lord", requiredLevel: 60, acquisition: "Craftable following Thinking Makes It So", prerequisite: "Thinking Makes It So" }, | ||
| 59 | }; | 65 | }; |
| 60 | 66 | ||
| 61 | -/** Fixed family order used to derive deterministic alternative frames. */ | 67 | +/** |
| 62 | -export const frameFamilyOrder: readonly SkellFrameFamily[] = [ | 68 | + * Quest- or story-gated frames outside the shop families; the engine ranks |
| 63 | - "Formula", | 69 | + * them against shop frames by strategy effectiveness, and the acquisition |
| 64 | - "Urban", | 70 | + * text tells the player which gate to clear. |
| 65 | - "Verus", | 71 | + * The level-60 entries only surface with the postgame-frames toggle. |
| 66 | - "Lailah", | 72 | + * Chapter-12-gated crafts carry no prerequisite: the toggle already means that. |
| 67 | - "Inferno", | 73 | + * Excluded: AS0115 Formula XT, a temporary loaner during A Girl's Wings. |
| 68 | - "Mastema", | 74 | + */ |
| 69 | - "Amdusias", | 75 | +export const specialFrames: Record<20 | 30 | 50 | 60, readonly SkellFrameFact[]> = { |
| 70 | -]; | 76 | + 20: [ |
| 77 | + { frame: "GD0920 Hraesvelg ST", requiredLevel: 20, acquisition: "Complete New Skell on the Block", prerequisite: "New Skell on the Block" }, | ||
| 78 | + ], | ||
| 79 | + 30: [ | ||
| 80 | + { frame: "US0830 Dozer", requiredLevel: 30, acquisition: "Craftable following Bozé's Ignorance", prerequisite: "Bozé's Ignorance" }, | ||
| 81 | + { frame: "US0232 Police", requiredLevel: 30, acquisition: "Craftable following Climbing the Ladder", prerequisite: "Climbing the Ladder" }, | ||
| 82 | + ], | ||
| 83 | + 50: [ | ||
| 84 | + { frame: "Ares Prime", requiredLevel: 50, acquisition: "Complete Chapter 13 (Act 1)", prerequisite: "Chapter 13 (Act 1)" }, | ||
| 85 | + { frame: "US0850 Excavator", requiredLevel: 50, acquisition: "Craftable following Rapid Misfire", prerequisite: "Rapid Misfire" }, | ||
| 86 | + ], | ||
| 87 | + 60: [ | ||
| 88 | + { frame: "Ares 90", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | ||
| 89 | + { frame: "Ares 70", requiredLevel: 60, acquisition: "Craftable following Chapter 12" }, | ||
| 90 | + { frame: "US0860 Excavator", requiredLevel: 60, acquisition: "Craftable following Rapid Misfire", prerequisite: "Rapid Misfire" }, | ||
| 91 | + ], | ||
| 92 | +}; | ||
| 93 | + | ||
| 94 | +/** Combat stats driving alternative-frame effectiveness; armor is 0 for every frame. */ | ||
| 95 | +export interface SkellFrameStats { | ||
| 96 | + hp: number; | ||
| 97 | + melee: number; | ||
| 98 | + ranged: number; | ||
| 99 | + potential: number; | ||
| 100 | + evasion: number; | ||
| 101 | + fuel: number; | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/** Canonical frame stats from the Skell frame table (same source as above). */ | ||
| 105 | +export const frameStats: Record<string, SkellFrameStats> = { | ||
| 106 | + "US0220 Urban ST": { hp: 5350, melee: 62, ranged: 46, potential: 180, evasion: 48, fuel: 7650 }, | ||
| 107 | + "AS0130 Formula ST": { hp: 8200, melee: 62, ranged: 46, potential: 250, evasion: 50, fuel: 8100 }, | ||
| 108 | + "US0230 Urban ST": { hp: 9000, melee: 81, ranged: 39, potential: 280, evasion: 48, fuel: 9180 }, | ||
| 109 | + "AS0330 Verus ST": { hp: 8500, melee: 70, ranged: 44, potential: 300, evasion: 48, fuel: 8640 }, | ||
| 110 | + "AS0430 Lailah ST": { hp: 10500, melee: 92, ranged: 46, potential: 200, evasion: 32, fuel: 12000 }, | ||
| 111 | + "AS0530 Inferno ST": { hp: 11000, melee: 74, ranged: 72, potential: 190, evasion: 30, fuel: 13800 }, | ||
| 112 | + "XS0630 Mastema ST": { hp: 11800, melee: 85, ranged: 83, potential: 120, evasion: 10, fuel: 18000 }, | ||
| 113 | + "XS0730 Amdusias ST": { hp: 13900, melee: 129, ranged: 46, potential: 100, evasion: 10, fuel: 25200 }, | ||
| 114 | + "GD0930 Hraesvelg ST": { hp: 7600, melee: 76, ranged: 70, potential: 370, evasion: 77, fuel: 7600 }, | ||
| 115 | + "AS0150 Formula ST": { hp: 12400, melee: 98, ranged: 72, potential: 420, evasion: 90, fuel: 8100 }, | ||
| 116 | + "US0250 Urban ST": { hp: 13500, melee: 127, ranged: 60, potential: 490, evasion: 88, fuel: 9180 }, | ||
| 117 | + "AS0350 Verus ST": { hp: 13000, melee: 110, ranged: 68, potential: 470, evasion: 88, fuel: 8640 }, | ||
| 118 | + "AS0450 Lailah ST": { hp: 15500, melee: 145, ranged: 72, potential: 350, evasion: 70, fuel: 12000 }, | ||
| 119 | + "AS0550 Inferno ST": { hp: 16000, melee: 116, ranged: 112, potential: 300, evasion: 68, fuel: 13800 }, | ||
| 120 | + "XS0650 Mastema ST": { hp: 17000, melee: 133, ranged: 128, potential: 200, evasion: 50, fuel: 18000 }, | ||
| 121 | + "XS0750 Amdusias ST": { hp: 19800, melee: 203, ranged: 72, potential: 150, evasion: 48, fuel: 25200 }, | ||
| 122 | + "GD0950 Hraesvelg ST": { hp: 11400, melee: 112, ranged: 104, potential: 570, evasion: 118, fuel: 7600 }, | ||
| 123 | + "Formula Zero": { hp: 17000, melee: 387, ranged: 381, potential: 650, evasion: 100, fuel: 10560 }, | ||
| 124 | + "Urban Lincoln": { hp: 18000, melee: 501, ranged: 318, potential: 700, evasion: 98, fuel: 11280 }, | ||
| 125 | + "Verus Cain": { hp: 17500, melee: 433, ranged: 360, potential: 680, evasion: 98, fuel: 10560 }, | ||
| 126 | + "Lailah Queen": { hp: 21000, melee: 570, ranged: 381, potential: 550, evasion: 80, fuel: 13920 }, | ||
| 127 | + "Inferno Skydon": { hp: 22000, melee: 456, ranged: 593, potential: 470, evasion: 78, fuel: 15600 }, | ||
| 128 | + "Mastema White Reaper": { hp: 23500, melee: 524, ranged: 678, potential: 300, evasion: 60, fuel: 20640 }, | ||
| 129 | + "Amdusias Hades": { hp: 25500, melee: 798, ranged: 381, potential: 200, evasion: 58, fuel: 28800 }, | ||
| 130 | + "Hraesvelg Lord": { hp: 15800, melee: 460, ranged: 448, potential: 860, evasion: 134, fuel: 9800 }, | ||
| 131 | + "GD0920 Hraesvelg ST": { hp: 5000, melee: 72, ranged: 66, potential: 290, evasion: 58, fuel: 7600 }, | ||
| 132 | + "US0830 Dozer": { hp: 12500, melee: 96, ranged: 92, potential: 200, evasion: 40, fuel: 18000 }, | ||
| 133 | + "US0232 Police": { hp: 11000, melee: 105, ranged: 49, potential: 320, evasion: 50, fuel: 11520 }, | ||
| 134 | + "Ares Prime": { hp: 20000, melee: 585, ranged: 425, potential: 350, evasion: 110, fuel: 17600 }, | ||
| 135 | + "US0850 Excavator": { hp: 22400, melee: 236, ranged: 40, potential: 240, evasion: 26, fuel: 26800 }, | ||
| 136 | + "Ares 70": { hp: 32000, melee: 643, ranged: 448, potential: 600, evasion: 100, fuel: 20160 }, | ||
| 137 | + "Ares 90": { hp: 41000, melee: 816, ranged: 568, potential: 900, evasion: 120, fuel: 24000 }, | ||
| 138 | + "US0860 Excavator": { hp: 33000, melee: 920, ranged: 320, potential: 500, evasion: 78, fuel: 26800 }, | ||
| 139 | +}; | ||
| 140 | + | ||
| 141 | +/** | ||
| 142 | + * Heuristic stat weights per strategy for alternative-frame effectiveness; | ||
| 143 | + * each stat is normalized by the tier maximum before weighting. | ||
| 144 | + */ | ||
| 145 | +export const strategyFrameWeights: Record<StrategyKey, Partial<Record<keyof SkellFrameStats, number>>> = { | ||
| 146 | + duelist: { melee: 1, hp: 0.4 }, | ||
| 147 | + bastion: { hp: 1, fuel: 0.3 }, | ||
| 148 | + jaguar: { evasion: 1, potential: 0.6 }, | ||
| 149 | + astral: { ranged: 1, hp: 0.3 }, | ||
| 150 | + mastermind: { potential: 1, ranged: 0.6 }, | ||
| 151 | + galactic: { melee: 1, potential: 0.6 }, | ||
| 152 | +}; | ||
| 71 | 153 | ||
| 72 | -/** Heuristic frame family by tactical strategy; the frame facts above remain canonical. */ | 154 | +/** |
| 155 | + * Heuristic frame family by tactical strategy; the frame facts above remain | ||
| 156 | + * canonical. Guide-informed: game8 crowns Lailah toughest overall, community | ||
| 157 | + * meta favors Amdusias melee, Mastema ranged, Verus beam, and — since DE — | ||
| 158 | + * Hraesvelg for evasion/potential builds over Formula. | ||
| 159 | + * Sources: https://game8.co/games/Xenoblade-Chronicles-X/archives/507490 | ||
| 160 | + * https://www.thegamer.com/xenoblade-chronicles-x-definitive-edition-best-skells/ | ||
| 161 | + */ | ||
| 73 | export const strategyFrameFamily: Record<StrategyKey, SkellFrameFamily> = { | 162 | export const strategyFrameFamily: Record<StrategyKey, SkellFrameFamily> = { |
| 74 | duelist: "Amdusias", | 163 | duelist: "Amdusias", |
| 75 | bastion: "Lailah", | 164 | bastion: "Lailah", |
| 76 | - jaguar: "Formula", | 165 | + jaguar: "Hraesvelg", |
| 77 | astral: "Mastema", | 166 | astral: "Mastema", |
| 78 | mastermind: "Inferno", | 167 | mastermind: "Inferno", |
| 79 | galactic: "Verus", | 168 | galactic: "Verus", |
modified
src/lib/engine.test.ts +42 -13 | @@ -170,28 +170,57 @@ test("Skell frames require license, pilot level, and explicit postgame access", | ||
| 170 | 170 | ); |
| 171 | 171 | }); |
| 172 | 172 | |
| 173 | -test("Skell alternatives offer two distinct same-tier frames", () => { | |
| 173 | +test("Skell alternatives rank every same-tier frame by strategy effectiveness", () => { | |
| 174 | 174 | assert.deepEqual( |
| 175 | - recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 20, hasLicense: true }), | |
| 175 | + recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }), | |
| 176 | 176 | [], |
| 177 | 177 | ); |
| 178 | 178 | |
| 179 | + const licenseBand = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 20, hasLicense: true }); | |
| 180 | + assert.deepEqual(licenseBand.map((alt) => alt.frame), ["GD0920 Hraesvelg ST"]); | |
| 181 | + | |
| 182 | + // duelist weights melee first, HP second; quest frames compete on the same scale. | |
| 179 | 183 | const tier30 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 30, hasLicense: true }); |
| 180 | - assert.equal(tier30.length, 2); | |
| 181 | - assert.ok(tier30.every((alt) => alt.requiredLevel === 30)); | |
| 182 | - assert.ok(tier30.every((alt) => !alt.frame.includes("Amdusias"))); | |
| 183 | - assert.notEqual(tier30[0]?.frame, tier30[1]?.frame); | |
| 184 | + assert.deepEqual(tier30.map((alt) => alt.frame), [ | |
| 185 | + "US0232 Police", | |
| 186 | + "US0830 Dozer", | |
| 187 | + "AS0430 Lailah ST", | |
| 188 | + "XS0630 Mastema ST", | |
| 189 | + "US0230 Urban ST", | |
| 190 | + "AS0530 Inferno ST", | |
| 191 | + "GD0930 Hraesvelg ST", | |
| 192 | + "AS0330 Verus ST", | |
| 193 | + "AS0130 Formula ST", | |
| 194 | + ]); | |
| 195 | + | |
| 196 | + const tier50 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 60, hasLicense: true }); | |
| 197 | + assert.deepEqual(tier50.map((alt) => alt.frame), [ | |
| 198 | + "Ares Prime", | |
| 199 | + "US0850 Excavator", | |
| 200 | + "XS0650 Mastema ST", | |
| 201 | + "AS0450 Lailah ST", | |
| 202 | + "AS0550 Inferno ST", | |
| 203 | + "US0250 Urban ST", | |
| 204 | + "AS0350 Verus ST", | |
| 205 | + "GD0950 Hraesvelg ST", | |
| 206 | + "AS0150 Formula ST", | |
| 207 | + ]); | |
| 184 | 208 | |
| 185 | 209 | const postgame = recommendSkellAlternatives({ |
| 186 | 210 | strategy: "duelist", pilotLevel: 60, hasLicense: true, postgameFrames: true, |
| 187 | 211 | }); |
| 188 | - assert.ok(postgame.every((alt) => alt.requiredLevel === 60)); | |
| 189 | - assert.ok(postgame.every((alt) => alt.acquisition.includes("Chapter 12"))); | |
| 190 | - | |
| 191 | - assert.deepEqual( | |
| 192 | - recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }), | |
| 193 | - [], | |
| 194 | - ); | |
| 212 | + assert.deepEqual(postgame.map((alt) => alt.frame), [ | |
| 213 | + "US0860 Excavator", | |
| 214 | + "Ares 90", | |
| 215 | + "Ares 70", | |
| 216 | + "Lailah Queen", | |
| 217 | + "Mastema White Reaper", | |
| 218 | + "Urban Lincoln", | |
| 219 | + "Inferno Skydon", | |
| 220 | + "Hraesvelg Lord", | |
| 221 | + "Verus Cain", | |
| 222 | + "Formula Zero", | |
| 223 | + ]); | |
| 195 | 224 | }); |
| 196 | 225 | |
| 197 | 226 | test("Skell loadout matches the strategy attribute and frame-level band", () => { |
| @@ -170,28 +170,57 @@ test("Skell frames require license, pilot level, and explicit postgame access", | |||
| 170 | ); | 170 | ); |
| 171 | }); | 171 | }); |
| 172 | 172 | ||
| 173 | -test("Skell alternatives offer two distinct same-tier frames", () => { | 173 | +test("Skell alternatives rank every same-tier frame by strategy effectiveness", () => { |
| 174 | assert.deepEqual( | 174 | assert.deepEqual( |
| 175 | - recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 20, hasLicense: true }), | 175 | + recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }), |
| 176 | [], | 176 | [], |
| 177 | ); | 177 | ); |
| 178 | 178 | ||
| 179 | + const licenseBand = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 20, hasLicense: true }); | ||
| 180 | + assert.deepEqual(licenseBand.map((alt) => alt.frame), ["GD0920 Hraesvelg ST"]); | ||
| 181 | + | ||
| 182 | + // duelist weights melee first, HP second; quest frames compete on the same scale. | ||
| 179 | const tier30 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 30, hasLicense: true }); | 183 | const tier30 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 30, hasLicense: true }); |
| 180 | - assert.equal(tier30.length, 2); | 184 | + assert.deepEqual(tier30.map((alt) => alt.frame), [ |
| 181 | - assert.ok(tier30.every((alt) => alt.requiredLevel === 30)); | 185 | + "US0232 Police", |
| 182 | - assert.ok(tier30.every((alt) => !alt.frame.includes("Amdusias"))); | 186 | + "US0830 Dozer", |
| 183 | - assert.notEqual(tier30[0]?.frame, tier30[1]?.frame); | 187 | + "AS0430 Lailah ST", |
| 188 | + "XS0630 Mastema ST", | ||
| 189 | + "US0230 Urban ST", | ||
| 190 | + "AS0530 Inferno ST", | ||
| 191 | + "GD0930 Hraesvelg ST", | ||
| 192 | + "AS0330 Verus ST", | ||
| 193 | + "AS0130 Formula ST", | ||
| 194 | + ]); | ||
| 195 | + | ||
| 196 | + const tier50 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 60, hasLicense: true }); | ||
| 197 | + assert.deepEqual(tier50.map((alt) => alt.frame), [ | ||
| 198 | + "Ares Prime", | ||
| 199 | + "US0850 Excavator", | ||
| 200 | + "XS0650 Mastema ST", | ||
| 201 | + "AS0450 Lailah ST", | ||
| 202 | + "AS0550 Inferno ST", | ||
| 203 | + "US0250 Urban ST", | ||
| 204 | + "AS0350 Verus ST", | ||
| 205 | + "GD0950 Hraesvelg ST", | ||
| 206 | + "AS0150 Formula ST", | ||
| 207 | + ]); | ||
| 184 | 208 | ||
| 185 | const postgame = recommendSkellAlternatives({ | 209 | const postgame = recommendSkellAlternatives({ |
| 186 | strategy: "duelist", pilotLevel: 60, hasLicense: true, postgameFrames: true, | 210 | strategy: "duelist", pilotLevel: 60, hasLicense: true, postgameFrames: true, |
| 187 | }); | 211 | }); |
| 188 | - assert.ok(postgame.every((alt) => alt.requiredLevel === 60)); | 212 | + assert.deepEqual(postgame.map((alt) => alt.frame), [ |
| 189 | - assert.ok(postgame.every((alt) => alt.acquisition.includes("Chapter 12"))); | 213 | + "US0860 Excavator", |
| 190 | - | 214 | + "Ares 90", |
| 191 | - assert.deepEqual( | 215 | + "Ares 70", |
| 192 | - recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }), | 216 | + "Lailah Queen", |
| 193 | - [], | 217 | + "Mastema White Reaper", |
| 194 | - ); | 218 | + "Urban Lincoln", |
| 219 | + "Inferno Skydon", | ||
| 220 | + "Hraesvelg Lord", | ||
| 221 | + "Verus Cain", | ||
| 222 | + "Formula Zero", | ||
| 223 | + ]); | ||
| 195 | }); | 224 | }); |
| 196 | 225 | ||
| 197 | test("Skell loadout matches the strategy attribute and frame-level band", () => { | 226 | test("Skell loadout matches the strategy attribute and frame-level band", () => { |
modified
src/lib/engine.ts +45 -22 | @@ -3,13 +3,16 @@ import { classes } from "@/src/data/classes"; | ||
| 3 | 3 | import { companionByName, companions } from "@/src/data/companions"; |
| 4 | 4 | import { classProgression, classProgressionById } from "@/src/data/progression"; |
| 5 | 5 | import { |
| 6 | - frameFamilyOrder, | |
| 6 | + frameStats, | |
| 7 | 7 | licenseFrame, |
| 8 | 8 | postgameFrames, |
| 9 | 9 | shopFrames, |
| 10 | 10 | skellSource, |
| 11 | + specialFrames, | |
| 11 | 12 | strategyFrameFamily, |
| 13 | + strategyFrameWeights, | |
| 12 | 14 | } from "@/src/data/skells"; |
| 15 | +import type { SkellFrameFact, SkellFrameStats } from "@/src/data/skells"; | |
| 13 | 16 | import { skellAugments, skellWeaponLoadouts } from "@/src/data/skellWeapons"; |
| 14 | 17 | import type { |
| 15 | 18 | ArtSelection, |
| @@ -780,36 +783,56 @@ export function recommendSkellFrame( | ||
| 780 | 783 | } |
| 781 | 784 | |
| 782 | 785 | /** |
| 783 | - * Two deterministic alternative frames at the same tier as the main | |
| 784 | - * recommendation: the next families in the fixed order. None below level 30, | |
| 785 | - * where the license frame is the only canonical option in our data. | |
| 786 | + * Every other frame at the main recommendation's tier, ranked by strategy | |
| 787 | + * effectiveness: the strategy's stat weights applied to canonical frame | |
| 788 | + * stats normalized by the tier maxima. Quest- or story-gated frames compete | |
| 789 | + * on the same scale; their acquisition text carries the gate. | |
| 786 | 790 | */ |
| 787 | 791 | export function recommendSkellAlternatives( |
| 788 | 792 | request: SkellRecommendationRequest, |
| 789 | 793 | ): readonly SkellFrameRecommendation[] { |
| 790 | 794 | const pilotLevel = clampLevel(request.pilotLevel); |
| 791 | - if (!request.hasLicense || pilotLevel < 30) return []; | |
| 795 | + if (!request.hasLicense || pilotLevel < 20) return []; | |
| 792 | 796 | |
| 793 | - const family = strategyFrameFamily[request.strategy]; | |
| 794 | - const start = frameFamilyOrder.indexOf(family); | |
| 795 | - const families = [1, 2].map( | |
| 796 | - (offset) => frameFamilyOrder[(start + offset) % frameFamilyOrder.length], | |
| 797 | + const postgame = pilotLevel >= 60 && request.postgameFrames === true; | |
| 798 | + const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30; | |
| 799 | + const pool: readonly SkellFrameFact[] = pilotLevel < 30 | |
| 800 | + ? specialFrames[20] | |
| 801 | + : postgame | |
| 802 | + ? [...Object.values(postgameFrames), ...specialFrames[60]] | |
| 803 | + : [...Object.values(shopFrames[tier]), ...specialFrames[tier]]; | |
| 804 | + | |
| 805 | + const mainFrame = recommendSkellFrame(request)?.frame; | |
| 806 | + const candidates = pool.filter((alt) => alt.frame !== mainFrame); | |
| 807 | + | |
| 808 | + const weights = strategyFrameWeights[request.strategy]; | |
| 809 | + const maxima = Object.fromEntries( | |
| 810 | + (Object.keys(weights) as (keyof SkellFrameStats)[]).map((stat) => [ | |
| 811 | + stat, | |
| 812 | + Math.max(...candidates.map((alt) => frameStats[alt.frame]?.[stat] ?? 0)), | |
| 813 | + ]), | |
| 797 | 814 | ); |
| 815 | + const effectiveness = (frame: string): number => | |
| 816 | + (Object.entries(weights) as [keyof SkellFrameStats, number][]).reduce( | |
| 817 | + (total, [stat, weight]) => | |
| 818 | + total + weight * ((frameStats[frame]?.[stat] ?? 0) / (maxima[stat] || 1)), | |
| 819 | + 0, | |
| 820 | + ); | |
| 798 | 821 | |
| 799 | - if (pilotLevel >= 60 && request.postgameFrames === true) { | |
| 800 | - return families.map((alt) => ({ | |
| 801 | - ...postgameFrames[alt], | |
| 802 | - reason: `Alternative postgame frame at the same tier (${alt})`, | |
| 822 | + return candidates | |
| 823 | + .map((alt) => ({ | |
| 824 | + ...alt, | |
| 825 | + reason: alt.prerequisite | |
| 826 | + ? `Quest-locked frame at the same tier (${alt.prerequisite})` | |
| 827 | + : postgame | |
| 828 | + ? "Postgame craftable frame at the same tier" | |
| 829 | + : "Alternative shop frame at the same tier", | |
| 803 | 830 | source: skellSource, |
| 804 | - })); | |
| 805 | - } | |
| 806 | - | |
| 807 | - const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30; | |
| 808 | - return families.map((alt) => ({ | |
| 809 | - ...shopFrames[tier][alt], | |
| 810 | - reason: `Alternative shop frame at the same tier (${alt})`, | |
| 811 | - source: skellSource, | |
| 812 | - })); | |
| 831 | + })) | |
| 832 | + .sort((left, right) => | |
| 833 | + effectiveness(right.frame) - effectiveness(left.frame) | |
| 834 | + || compareText(left.frame, right.frame), | |
| 835 | + ); | |
| 813 | 836 | } |
| 814 | 837 | |
| 815 | 838 | /** |
| @@ -3,13 +3,16 @@ import { classes } from "@/src/data/classes"; | |||
| 3 | import { companionByName, companions } from "@/src/data/companions"; | 3 | import { companionByName, companions } from "@/src/data/companions"; |
| 4 | import { classProgression, classProgressionById } from "@/src/data/progression"; | 4 | import { classProgression, classProgressionById } from "@/src/data/progression"; |
| 5 | import { | 5 | import { |
| 6 | - frameFamilyOrder, | 6 | + frameStats, |
| 7 | licenseFrame, | 7 | licenseFrame, |
| 8 | postgameFrames, | 8 | postgameFrames, |
| 9 | shopFrames, | 9 | shopFrames, |
| 10 | skellSource, | 10 | skellSource, |
| 11 | + specialFrames, | ||
| 11 | strategyFrameFamily, | 12 | strategyFrameFamily, |
| 13 | + strategyFrameWeights, | ||
| 12 | } from "@/src/data/skells"; | 14 | } from "@/src/data/skells"; |
| 15 | +import type { SkellFrameFact, SkellFrameStats } from "@/src/data/skells"; | ||
| 13 | import { skellAugments, skellWeaponLoadouts } from "@/src/data/skellWeapons"; | 16 | import { skellAugments, skellWeaponLoadouts } from "@/src/data/skellWeapons"; |
| 14 | import type { | 17 | import type { |
| 15 | ArtSelection, | 18 | ArtSelection, |
| @@ -780,36 +783,56 @@ export function recommendSkellFrame( | |||
| 780 | } | 783 | } |
| 781 | 784 | ||
| 782 | /** | 785 | /** |
| 783 | - * Two deterministic alternative frames at the same tier as the main | 786 | + * Every other frame at the main recommendation's tier, ranked by strategy |
| 784 | - * recommendation: the next families in the fixed order. None below level 30, | 787 | + * effectiveness: the strategy's stat weights applied to canonical frame |
| 785 | - * where the license frame is the only canonical option in our data. | 788 | + * stats normalized by the tier maxima. Quest- or story-gated frames compete |
| 789 | + * on the same scale; their acquisition text carries the gate. | ||
| 786 | */ | 790 | */ |
| 787 | export function recommendSkellAlternatives( | 791 | export function recommendSkellAlternatives( |
| 788 | request: SkellRecommendationRequest, | 792 | request: SkellRecommendationRequest, |
| 789 | ): readonly SkellFrameRecommendation[] { | 793 | ): readonly SkellFrameRecommendation[] { |
| 790 | const pilotLevel = clampLevel(request.pilotLevel); | 794 | const pilotLevel = clampLevel(request.pilotLevel); |
| 791 | - if (!request.hasLicense || pilotLevel < 30) return []; | 795 | + if (!request.hasLicense || pilotLevel < 20) return []; |
| 792 | 796 | ||
| 793 | - const family = strategyFrameFamily[request.strategy]; | 797 | + const postgame = pilotLevel >= 60 && request.postgameFrames === true; |
| 794 | - const start = frameFamilyOrder.indexOf(family); | 798 | + const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30; |
| 795 | - const families = [1, 2].map( | 799 | + const pool: readonly SkellFrameFact[] = pilotLevel < 30 |
| 796 | - (offset) => frameFamilyOrder[(start + offset) % frameFamilyOrder.length], | 800 | + ? specialFrames[20] |
| 801 | + : postgame | ||
| 802 | + ? [...Object.values(postgameFrames), ...specialFrames[60]] | ||
| 803 | + : [...Object.values(shopFrames[tier]), ...specialFrames[tier]]; | ||
| 804 | + | ||
| 805 | + const mainFrame = recommendSkellFrame(request)?.frame; | ||
| 806 | + const candidates = pool.filter((alt) => alt.frame !== mainFrame); | ||
| 807 | + | ||
| 808 | + const weights = strategyFrameWeights[request.strategy]; | ||
| 809 | + const maxima = Object.fromEntries( | ||
| 810 | + (Object.keys(weights) as (keyof SkellFrameStats)[]).map((stat) => [ | ||
| 811 | + stat, | ||
| 812 | + Math.max(...candidates.map((alt) => frameStats[alt.frame]?.[stat] ?? 0)), | ||
| 813 | + ]), | ||
| 797 | ); | 814 | ); |
| 815 | + const effectiveness = (frame: string): number => | ||
| 816 | + (Object.entries(weights) as [keyof SkellFrameStats, number][]).reduce( | ||
| 817 | + (total, [stat, weight]) => | ||
| 818 | + total + weight * ((frameStats[frame]?.[stat] ?? 0) / (maxima[stat] || 1)), | ||
| 819 | + 0, | ||
| 820 | + ); | ||
| 798 | 821 | ||
| 799 | - if (pilotLevel >= 60 && request.postgameFrames === true) { | 822 | + return candidates |
| 800 | - return families.map((alt) => ({ | 823 | + .map((alt) => ({ |
| 801 | - ...postgameFrames[alt], | 824 | + ...alt, |
| 802 | - reason: `Alternative postgame frame at the same tier (${alt})`, | 825 | + reason: alt.prerequisite |
| 826 | + ? `Quest-locked frame at the same tier (${alt.prerequisite})` | ||
| 827 | + : postgame | ||
| 828 | + ? "Postgame craftable frame at the same tier" | ||
| 829 | + : "Alternative shop frame at the same tier", | ||
| 803 | source: skellSource, | 830 | source: skellSource, |
| 804 | - })); | 831 | + })) |
| 805 | - } | 832 | + .sort((left, right) => |
| 806 | - | 833 | + effectiveness(right.frame) - effectiveness(left.frame) |
| 807 | - const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30; | 834 | + || compareText(left.frame, right.frame), |
| 808 | - return families.map((alt) => ({ | 835 | + ); |
| 809 | - ...shopFrames[tier][alt], | ||
| 810 | - reason: `Alternative shop frame at the same tier (${alt})`, | ||
| 811 | - source: skellSource, | ||
| 812 | - })); | ||
| 813 | } | 836 | } |
| 814 | 837 | ||
| 815 | /** | 838 | /** |
modified
src/lib/types.ts +2 -0 | @@ -188,6 +188,8 @@ export interface SkellFrameRecommendation { | ||
| 188 | 188 | frame: string; |
| 189 | 189 | requiredLevel: number; |
| 190 | 190 | acquisition: string; |
| 191 | + /** Gating quest or story beat beyond the license; shown as a warning. */ | |
| 192 | + prerequisite?: string; | |
| 191 | 193 | reason: string; |
| 192 | 194 | source: string; |
| 193 | 195 | } |
| @@ -188,6 +188,8 @@ export interface SkellFrameRecommendation { | |||
| 188 | frame: string; | 188 | frame: string; |
| 189 | requiredLevel: number; | 189 | requiredLevel: number; |
| 190 | acquisition: string; | 190 | acquisition: string; |
| 191 | + /** Gating quest or story beat beyond the license; shown as a warning. */ | ||
| 192 | + prerequisite?: string; | ||
| 191 | reason: string; | 193 | reason: string; |
| 192 | source: string; | 194 | source: string; |
| 193 | } | 195 | } |