anthodev/xeno-team-builderpublic Fork
⑂ 1adde03
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/xeno-team-builder.git
git clone ssh://git@rickub.com/anthodev/xeno-team-builder.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: suppport skells from quests

Cedric ANTHONY committed 2026-08-04T00:56:01+02:00 Browse files
1adde03 parent: a6f0a51
modified src/data/skells.ts +102 -13
@@ -7,12 +7,15 @@ export type SkellFrameFamily =
77 | "Lailah"
88 | "Inferno"
99 | "Mastema"
10- | "Amdusias";
10+ | "Amdusias"
11+ | "Hraesvelg";
1112
1213 export interface SkellFrameFact {
1314 frame: string;
1415 requiredLevel: number;
1516 acquisition: string;
17+ /** Gating quest or story beat beyond the license; sorts alternatives last. */
18+ prerequisite?: string;
1619 }
1720
1821 export const skellSource =
@@ -35,6 +38,7 @@ export const shopFrames: Record<30 | 50, Record<SkellFrameFamily, SkellFrameFact
3538 Inferno: { frame: "AS0530 Inferno ST", requiredLevel: 30, acquisition: "Shop purchase" },
3639 Mastema: { frame: "XS0630 Mastema ST", requiredLevel: 30, acquisition: "Shop purchase" },
3740 Amdusias: { frame: "XS0730 Amdusias ST", requiredLevel: 30, acquisition: "Shop purchase" },
41+ Hraesvelg: { frame: "GD0930 Hraesvelg ST", requiredLevel: 30, acquisition: "Shop purchase" },
3842 },
3943 50: {
4044 Formula: { frame: "AS0150 Formula ST", requiredLevel: 50, acquisition: "Shop purchase" },
@@ -44,6 +48,7 @@ export const shopFrames: Record<30 | 50, Record<SkellFrameFamily, SkellFrameFact
4448 Inferno: { frame: "AS0550 Inferno ST", requiredLevel: 50, acquisition: "Shop purchase" },
4549 Mastema: { frame: "XS0650 Mastema ST", requiredLevel: 50, acquisition: "Shop purchase" },
4650 Amdusias: { frame: "XS0750 Amdusias ST", requiredLevel: 50, acquisition: "Shop purchase" },
51+ Hraesvelg: { frame: "GD0950 Hraesvelg ST", requiredLevel: 50, acquisition: "Shop purchase" },
4752 },
4853 };
4954
@@ -56,24 +61,108 @@ export const postgameFrames: Record<SkellFrameFamily, SkellFrameFact> = {
5661 Inferno: { frame: "Inferno Skydon", requiredLevel: 60, acquisition: "Craftable following Chapter 12" },
5762 Mastema: { frame: "Mastema White Reaper", requiredLevel: 60, acquisition: "Craftable following Chapter 12" },
5863 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" },
5965 };
6066
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+};
71153
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+ */
73162 export const strategyFrameFamily: Record<StrategyKey, SkellFrameFamily> = {
74163 duelist: "Amdusias",
75164 bastion: "Lailah",
76- jaguar: "Formula",
165+ jaguar: "Hraesvelg",
77166 astral: "Mastema",
78167 mastermind: "Inferno",
79168 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",
170170 );
171171 });
172172
173-test("Skell alternatives offer two distinct same-tier frames", () => {
173+test("Skell alternatives rank every same-tier frame by strategy effectiveness", () => {
174174 assert.deepEqual(
175- recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 20, hasLicense: true }),
175+ recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }),
176176 [],
177177 );
178178
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.
179183 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+ ]);
184208
185209 const postgame = recommendSkellAlternatives({
186210 strategy: "duelist", pilotLevel: 60, hasLicense: true, postgameFrames: true,
187211 });
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+ ]);
195224 });
196225
197226 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";
33 import { companionByName, companions } from "@/src/data/companions";
44 import { classProgression, classProgressionById } from "@/src/data/progression";
55 import {
6- frameFamilyOrder,
6+ frameStats,
77 licenseFrame,
88 postgameFrames,
99 shopFrames,
1010 skellSource,
11+ specialFrames,
1112 strategyFrameFamily,
13+ strategyFrameWeights,
1214 } from "@/src/data/skells";
15+import type { SkellFrameFact, SkellFrameStats } from "@/src/data/skells";
1316 import { skellAugments, skellWeaponLoadouts } from "@/src/data/skellWeapons";
1417 import type {
1518 ArtSelection,
@@ -780,36 +783,56 @@ export function recommendSkellFrame(
780783 }
781784
782785 /**
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.
786790 */
787791 export function recommendSkellAlternatives(
788792 request: SkellRecommendationRequest,
789793 ): readonly SkellFrameRecommendation[] {
790794 const pilotLevel = clampLevel(request.pilotLevel);
791- if (!request.hasLicense || pilotLevel < 30) return [];
795+ if (!request.hasLicense || pilotLevel < 20) return [];
792796
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+ ]),
797814 );
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+ );
798821
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",
803830 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+ );
813836 }
814837
815838 /**
@@ -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 main786+ * 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 {
188188 frame: string;
189189 requiredLevel: number;
190190 acquisition: string;
191+ /** Gating quest or story beat beyond the license; shown as a warning. */
192+ prerequisite?: string;
191193 reason: string;
192194 source: string;
193195 }
@@ -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 }