anthodev/xeno-team-builderpublic Fork
⑂ 3fe3ee6
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: localize Skell recommendations and refine build results

Cedric ANTHONY committed 2026-08-04T18:20:26+02:00 Browse files
3fe3ee6 parent: 1adde03
modified app/globals.css +1 -1
@@ -66,7 +66,7 @@ select:focus { border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.0
6666 .chip-list{display:grid;gap:6px}.chip-list>span{display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid rgba(110,243,197,.2);background:rgba(110,243,197,.05);color:#9ec3b8;padding:7px 9px;font-size:12px;line-height:1.35}.chip-list b{font-weight:500}.chip-list .lvl-badge{margin-left:auto;flex-shrink:0}.stat-priority-list,.plan-reasons{margin:0;padding-left:20px;color:#789698;font-size:12px;line-height:1.55}.am-note{margin:0;display:grid;gap:2px}.am-note b{font-size:13px;color:#dbeae7;font-weight:500}.am-note span{font-size:12px;color:#789698;line-height:1.45}.stat-priority-list{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px}.plan-reasons{display:grid;gap:5px}.empty-loadout{border-left:1px solid var(--orange);padding-left:9px;color:#708f91;font-size:12px;line-height:1.5}
6767 .skell-frame{border:1px solid rgba(255,113,79,.2);padding:11px;background:rgba(255,113,79,.04)}.skell-frame small,.skell-frame strong,.skell-frame span{display:block}.skell-frame small{font-size:11px;letter-spacing:.18em;color:#8f6c65}.skell-frame strong{font-size:28px;color:#ff9479;font-weight:300;margin:3px 0}.skell-frame span{font-size:12px;color:#896f6a}.skell-facts{display:grid;gap:0;margin:12px 0 0}.skell-facts>div{border-top:1px solid rgba(150,239,240,.09);padding:9px 0}.skell-facts dt{font-size:11px;letter-spacing:.12em;color:#5f8183;text-transform:uppercase}.skell-facts dd{margin:4px 0 0;color:#9eb5b4;font-size:12px;line-height:1.45}.skell-unavailable{border:1px solid rgba(255,113,79,.2);padding:14px;background:rgba(255,113,79,.04)}.skell-unavailable strong{color:#ff9479;font-size:13px;letter-spacing:.12em}.skell-unavailable p{margin:7px 0 0;color:#896f6a;font-size:13px;line-height:1.5}
6868 .skell-alts{margin:12px 0;opacity:.62}.skell-alts>small{display:block;font-size:10px;letter-spacing:.18em;color:#5d8487;text-transform:uppercase;margin-bottom:5px}.skell-alts p{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin:0;padding:4px 0;border-top:1px solid rgba(150,239,240,.07)}.skell-alts b{font-size:13px;color:#9eb5b4;font-weight:400}.skell-alts span{font-size:11px;color:#537477}
69-.skell-gear-list{list-style:none;margin:0 0 14px;padding:0;display:grid;gap:6px}.skell-gear-list li{display:grid;gap:2px;border-left:2px solid rgba(150,239,240,.18);padding:4px 0 4px 10px}.skell-gear-list b{font-size:13px;color:#dbeae7;font-weight:500}.skell-gear-list small{font-size:11px;color:#5d8487;letter-spacing:.08em;text-transform:uppercase}
69+.skell-gear-list{list-style:none;margin:0 0 14px;padding:0;display:grid;gap:6px}.skell-gear-list li{display:grid;gap:2px;border-left:2px solid rgba(150,239,240,.18);padding:4px 0 4px 10px}.skell-gear-list b{font-size:13px;color:#dbeae7;font-weight:500}.skell-gear-list small{font-size:11px;color:#5d8487;letter-spacing:.08em;text-transform:uppercase}.skell-gear-acquisition{font-size:11px;color:#537477;letter-spacing:.02em;line-height:1.35;text-transform:none}
7070 .lvl-badge { display:inline-block;border:1px solid rgba(150,239,240,.35);color:var(--cyan);font-size:10px;font-style:normal;letter-spacing:.05em;padding:2px 5px;text-decoration:none;line-height:1.35 }
7171 .lvl-badge:hover,.lvl-badge:focus-visible{border-color:var(--cyan);background:rgba(150,239,240,.08)}
7272 .method-note{display:grid;grid-template-columns:40px 1fr;gap:15px;margin-top:18px;border:1px solid var(--line);padding:20px;background:rgba(7,26,31,.7)}.method-note>span{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--orange);color:var(--orange)}.method-note h3{font-size:16px;margin:2px 0 7px}.method-note p{font-size:14px;line-height:1.65;color:#789698;margin:0;max-width:1200px}
@@ -66,7 +66,7 @@ select:focus { border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.0
66 .chip-list{display:grid;gap:6px}.chip-list>span{display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid rgba(110,243,197,.2);background:rgba(110,243,197,.05);color:#9ec3b8;padding:7px 9px;font-size:12px;line-height:1.35}.chip-list b{font-weight:500}.chip-list .lvl-badge{margin-left:auto;flex-shrink:0}.stat-priority-list,.plan-reasons{margin:0;padding-left:20px;color:#789698;font-size:12px;line-height:1.55}.am-note{margin:0;display:grid;gap:2px}.am-note b{font-size:13px;color:#dbeae7;font-weight:500}.am-note span{font-size:12px;color:#789698;line-height:1.45}.stat-priority-list{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px}.plan-reasons{display:grid;gap:5px}.empty-loadout{border-left:1px solid var(--orange);padding-left:9px;color:#708f91;font-size:12px;line-height:1.5}66 .chip-list{display:grid;gap:6px}.chip-list>span{display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid rgba(110,243,197,.2);background:rgba(110,243,197,.05);color:#9ec3b8;padding:7px 9px;font-size:12px;line-height:1.35}.chip-list b{font-weight:500}.chip-list .lvl-badge{margin-left:auto;flex-shrink:0}.stat-priority-list,.plan-reasons{margin:0;padding-left:20px;color:#789698;font-size:12px;line-height:1.55}.am-note{margin:0;display:grid;gap:2px}.am-note b{font-size:13px;color:#dbeae7;font-weight:500}.am-note span{font-size:12px;color:#789698;line-height:1.45}.stat-priority-list{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px}.plan-reasons{display:grid;gap:5px}.empty-loadout{border-left:1px solid var(--orange);padding-left:9px;color:#708f91;font-size:12px;line-height:1.5}
67 .skell-frame{border:1px solid rgba(255,113,79,.2);padding:11px;background:rgba(255,113,79,.04)}.skell-frame small,.skell-frame strong,.skell-frame span{display:block}.skell-frame small{font-size:11px;letter-spacing:.18em;color:#8f6c65}.skell-frame strong{font-size:28px;color:#ff9479;font-weight:300;margin:3px 0}.skell-frame span{font-size:12px;color:#896f6a}.skell-facts{display:grid;gap:0;margin:12px 0 0}.skell-facts>div{border-top:1px solid rgba(150,239,240,.09);padding:9px 0}.skell-facts dt{font-size:11px;letter-spacing:.12em;color:#5f8183;text-transform:uppercase}.skell-facts dd{margin:4px 0 0;color:#9eb5b4;font-size:12px;line-height:1.45}.skell-unavailable{border:1px solid rgba(255,113,79,.2);padding:14px;background:rgba(255,113,79,.04)}.skell-unavailable strong{color:#ff9479;font-size:13px;letter-spacing:.12em}.skell-unavailable p{margin:7px 0 0;color:#896f6a;font-size:13px;line-height:1.5}67 .skell-frame{border:1px solid rgba(255,113,79,.2);padding:11px;background:rgba(255,113,79,.04)}.skell-frame small,.skell-frame strong,.skell-frame span{display:block}.skell-frame small{font-size:11px;letter-spacing:.18em;color:#8f6c65}.skell-frame strong{font-size:28px;color:#ff9479;font-weight:300;margin:3px 0}.skell-frame span{font-size:12px;color:#896f6a}.skell-facts{display:grid;gap:0;margin:12px 0 0}.skell-facts>div{border-top:1px solid rgba(150,239,240,.09);padding:9px 0}.skell-facts dt{font-size:11px;letter-spacing:.12em;color:#5f8183;text-transform:uppercase}.skell-facts dd{margin:4px 0 0;color:#9eb5b4;font-size:12px;line-height:1.45}.skell-unavailable{border:1px solid rgba(255,113,79,.2);padding:14px;background:rgba(255,113,79,.04)}.skell-unavailable strong{color:#ff9479;font-size:13px;letter-spacing:.12em}.skell-unavailable p{margin:7px 0 0;color:#896f6a;font-size:13px;line-height:1.5}
68 .skell-alts{margin:12px 0;opacity:.62}.skell-alts>small{display:block;font-size:10px;letter-spacing:.18em;color:#5d8487;text-transform:uppercase;margin-bottom:5px}.skell-alts p{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin:0;padding:4px 0;border-top:1px solid rgba(150,239,240,.07)}.skell-alts b{font-size:13px;color:#9eb5b4;font-weight:400}.skell-alts span{font-size:11px;color:#537477}68 .skell-alts{margin:12px 0;opacity:.62}.skell-alts>small{display:block;font-size:10px;letter-spacing:.18em;color:#5d8487;text-transform:uppercase;margin-bottom:5px}.skell-alts p{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin:0;padding:4px 0;border-top:1px solid rgba(150,239,240,.07)}.skell-alts b{font-size:13px;color:#9eb5b4;font-weight:400}.skell-alts span{font-size:11px;color:#537477}
69-.skell-gear-list{list-style:none;margin:0 0 14px;padding:0;display:grid;gap:6px}.skell-gear-list li{display:grid;gap:2px;border-left:2px solid rgba(150,239,240,.18);padding:4px 0 4px 10px}.skell-gear-list b{font-size:13px;color:#dbeae7;font-weight:500}.skell-gear-list small{font-size:11px;color:#5d8487;letter-spacing:.08em;text-transform:uppercase}69+.skell-gear-list{list-style:none;margin:0 0 14px;padding:0;display:grid;gap:6px}.skell-gear-list li{display:grid;gap:2px;border-left:2px solid rgba(150,239,240,.18);padding:4px 0 4px 10px}.skell-gear-list b{font-size:13px;color:#dbeae7;font-weight:500}.skell-gear-list small{font-size:11px;color:#5d8487;letter-spacing:.08em;text-transform:uppercase}.skell-gear-acquisition{font-size:11px;color:#537477;letter-spacing:.02em;line-height:1.35;text-transform:none}
70 .lvl-badge { display:inline-block;border:1px solid rgba(150,239,240,.35);color:var(--cyan);font-size:10px;font-style:normal;letter-spacing:.05em;padding:2px 5px;text-decoration:none;line-height:1.35 }70 .lvl-badge { display:inline-block;border:1px solid rgba(150,239,240,.35);color:var(--cyan);font-size:10px;font-style:normal;letter-spacing:.05em;padding:2px 5px;text-decoration:none;line-height:1.35 }
71 .lvl-badge:hover,.lvl-badge:focus-visible{border-color:var(--cyan);background:rgba(150,239,240,.08)}71 .lvl-badge:hover,.lvl-badge:focus-visible{border-color:var(--cyan);background:rgba(150,239,240,.08)}
72 .method-note{display:grid;grid-template-columns:40px 1fr;gap:15px;margin-top:18px;border:1px solid var(--line);padding:20px;background:rgba(7,26,31,.7)}.method-note>span{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--orange);color:var(--orange)}.method-note h3{font-size:16px;margin:2px 0 7px}.method-note p{font-size:14px;line-height:1.65;color:#789698;margin:0;max-width:1200px}72 .method-note{display:grid;grid-template-columns:40px 1fr;gap:15px;margin-top:18px;border:1px solid var(--line);padding:20px;background:rgba(7,26,31,.7)}.method-note>span{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--orange);color:var(--orange)}.method-note h3{font-size:16px;margin:2px 0 7px}.method-note p{font-size:14px;line-height:1.65;color:#789698;margin:0;max-width:1200px}
modified src/components/BuildCard.tsx +13 -9
@@ -1,5 +1,6 @@
11 import { armsManufacturers } from "@/src/data/arms";
22 import { buildStrategies } from "@/src/data/builds";
3+import { localizedGameUiText } from "@/src/data/i18n";
34 import type { Copy } from "@/src/data/i18n";
45 import {
56 localized,
@@ -33,6 +34,7 @@ export default function BuildCard({
3334 pilotLevel: member.effectiveLevel,
3435 hasLicense: hasSkellLicense,
3536 postgameFrames,
37+ lang,
3638 });
3739 const loadout = skell ? recommendSkellLoadout(member.strategy, skell.requiredLevel) : null;
3840 const alternatives = skell
@@ -41,6 +43,7 @@ export default function BuildCard({
4143 pilotLevel: member.effectiveLevel,
4244 hasLicense: hasSkellLicense,
4345 postgameFrames,
46+ lang,
4447 })
4548 : [];
4649
@@ -48,7 +51,7 @@ export default function BuildCard({
4851 <article className="build-card">
4952 <header className="build-card-head">
5053 <span className="member-number">{String(index + 1).padStart(2, "0")}</span>
51- <div><h3>{member.name}</h3><p>{member.className} · LV {member.effectiveLevel} · RANK {member.rank}</p></div>
54+ <div><h3>{member.name}</h3><p>{member.className} · {t.levelShort} {member.effectiveLevel} · {t.personalRankShort} {member.rank}</p></div>
5255 <div className="role-tag"><small>{strategy.damage}</small><b>{localized(strategy.role, lang)}</b></div>
5356 </header>
5457 <div className="build-columns">
@@ -62,7 +65,7 @@ export default function BuildCard({
6265 <b title={art.reason}>{art.name}</b>
6366 <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small>
6467 <a className="lvl-badge" href={art.source.url} target="_blank" rel="noreferrer" title={art.source.citation}>
65- {art.source.id} · RANK {art.unlockRank}
68+ {art.source.id} · {t.personalRankShort} {art.unlockRank}
6669 </a>
6770 </li>
6871 ))}
@@ -76,7 +79,7 @@ export default function BuildCard({
7679 <span key={`${skill.source.id}-${skill.name}`}>
7780 <b title={skill.reason}>{skill.name}</b>
7881 <a className="lvl-badge" href={skill.source.url} target="_blank" rel="noreferrer" title={skill.source.citation}>
79- {skill.source.id} · RANK {skill.unlockRank}
82+ {skill.source.id} · {t.personalRankShort} {skill.unlockRank}
8083 </a>
8184 </span>
8285 ))}
@@ -84,7 +87,7 @@ export default function BuildCard({
8487
8588 <h5>{t.heuristicStatPriority}</h5>
8689 <ol className="stat-priority-list">
87- {strategy.statPriority.map((stat) => <li key={stat}>{stat}</li>)}
90+ {strategy.statPriority.map((stat) => <li key={stat}>{localizedGameUiText(stat, lang)}</li>)}
8891 </ol>
8992
9093 <h5>{t.amRecommended}</h5>
@@ -100,19 +103,19 @@ export default function BuildCard({
100103 <h4><span>K</span>{t.skellSection}</h4>
101104 {skell && loadout ? <>
102105 <div className="skell-frame">
103- <small>FRAME</small>
106+ <small>{t.skellFrame}</small>
104107 <strong>{skell.frame}</strong>
105108 <span>{localized(strategy.skellRole, lang)}</span>
106109 </div>
107110 <dl className="skell-facts">
108- <div><dt>{t.requiredPilotLevel}</dt><dd>LV {skell.requiredLevel}</dd></div>
109- <div><dt>{t.acquisition}</dt><dd>{skell.acquisition}</dd></div>
111+ <div><dt>{t.requiredPilotLevel}</dt><dd>{t.levelShort} {skell.requiredLevel}</dd></div>
112+ <div><dt>{t.acquisition}</dt><dd>{localizedGameUiText(skell.acquisition, lang)}</dd></div>
110113 <div><dt>{t.reason}</dt><dd>{skell.reason}</dd></div>
111114 </dl>
112115 {alternatives.length > 0 && <div className="skell-alts">
113116 <small>{t.skellAlternatives}</small>
114117 {alternatives.map((alt) => (
115- <p key={alt.frame}><b>{alt.frame}</b><span>{alt.acquisition}</span></p>
118+ <p key={alt.frame}><b>{alt.frame}</b><span>{localizedGameUiText(alt.acquisition, lang)}</span></p>
116119 ))}
117120 </div>}
118121 <h5>{t.skellWeapons}</h5>
@@ -120,7 +123,8 @@ export default function BuildCard({
120123 {loadout.weapons.map((weapon) => (
121124 <li key={weapon.name}>
122125 <b>{weapon.name}</b>
123- <small>{weapon.slot} · {weapon.attribute} · {weapon.acquisition}</small>
126+ <small>{localizedGameUiText(weapon.slot, lang)} · {localizedGameUiText(weapon.attribute, lang)}</small>
127+ <span className="skell-gear-acquisition">{localizedGameUiText(weapon.acquisition, lang)}</span>
124128 </li>
125129 ))}
126130 </ul>
@@ -1,5 +1,6 @@
1 import { armsManufacturers } from "@/src/data/arms";1 import { armsManufacturers } from "@/src/data/arms";
2 import { buildStrategies } from "@/src/data/builds";2 import { buildStrategies } from "@/src/data/builds";
3+import { localizedGameUiText } from "@/src/data/i18n";
3 import type { Copy } from "@/src/data/i18n";4 import type { Copy } from "@/src/data/i18n";
4 import {5 import {
5 localized,6 localized,
@@ -33,6 +34,7 @@ export default function BuildCard({
33 pilotLevel: member.effectiveLevel,34 pilotLevel: member.effectiveLevel,
34 hasLicense: hasSkellLicense,35 hasLicense: hasSkellLicense,
35 postgameFrames,36 postgameFrames,
37+ lang,
36 });38 });
37 const loadout = skell ? recommendSkellLoadout(member.strategy, skell.requiredLevel) : null;39 const loadout = skell ? recommendSkellLoadout(member.strategy, skell.requiredLevel) : null;
38 const alternatives = skell40 const alternatives = skell
@@ -41,6 +43,7 @@ export default function BuildCard({
41 pilotLevel: member.effectiveLevel,43 pilotLevel: member.effectiveLevel,
42 hasLicense: hasSkellLicense,44 hasLicense: hasSkellLicense,
43 postgameFrames,45 postgameFrames,
46+ lang,
44 })47 })
45 : [];48 : [];
46 49
@@ -48,7 +51,7 @@ export default function BuildCard({
48 <article className="build-card">51 <article className="build-card">
49 <header className="build-card-head">52 <header className="build-card-head">
50 <span className="member-number">{String(index + 1).padStart(2, "0")}</span>53 <span className="member-number">{String(index + 1).padStart(2, "0")}</span>
51- <div><h3>{member.name}</h3><p>{member.className} · LV {member.effectiveLevel} · RANK {member.rank}</p></div>54+ <div><h3>{member.name}</h3><p>{member.className} · {t.levelShort} {member.effectiveLevel} · {t.personalRankShort} {member.rank}</p></div>
52 <div className="role-tag"><small>{strategy.damage}</small><b>{localized(strategy.role, lang)}</b></div>55 <div className="role-tag"><small>{strategy.damage}</small><b>{localized(strategy.role, lang)}</b></div>
53 </header>56 </header>
54 <div className="build-columns">57 <div className="build-columns">
@@ -62,7 +65,7 @@ export default function BuildCard({
62 <b title={art.reason}>{art.name}</b>65 <b title={art.reason}>{art.name}</b>
63 <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small>66 <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small>
64 <a className="lvl-badge" href={art.source.url} target="_blank" rel="noreferrer" title={art.source.citation}>67 <a className="lvl-badge" href={art.source.url} target="_blank" rel="noreferrer" title={art.source.citation}>
65- {art.source.id} · RANK {art.unlockRank}68+ {art.source.id} · {t.personalRankShort} {art.unlockRank}
66 </a>69 </a>
67 </li>70 </li>
68 ))}71 ))}
@@ -76,7 +79,7 @@ export default function BuildCard({
76 <span key={`${skill.source.id}-${skill.name}`}>79 <span key={`${skill.source.id}-${skill.name}`}>
77 <b title={skill.reason}>{skill.name}</b>80 <b title={skill.reason}>{skill.name}</b>
78 <a className="lvl-badge" href={skill.source.url} target="_blank" rel="noreferrer" title={skill.source.citation}>81 <a className="lvl-badge" href={skill.source.url} target="_blank" rel="noreferrer" title={skill.source.citation}>
79- {skill.source.id} · RANK {skill.unlockRank}82+ {skill.source.id} · {t.personalRankShort} {skill.unlockRank}
80 </a>83 </a>
81 </span>84 </span>
82 ))}85 ))}
@@ -84,7 +87,7 @@ export default function BuildCard({
84 87
85 <h5>{t.heuristicStatPriority}</h5>88 <h5>{t.heuristicStatPriority}</h5>
86 <ol className="stat-priority-list">89 <ol className="stat-priority-list">
87- {strategy.statPriority.map((stat) => <li key={stat}>{stat}</li>)}90+ {strategy.statPriority.map((stat) => <li key={stat}>{localizedGameUiText(stat, lang)}</li>)}
88 </ol>91 </ol>
89 92
90 <h5>{t.amRecommended}</h5>93 <h5>{t.amRecommended}</h5>
@@ -100,19 +103,19 @@ export default function BuildCard({
100 <h4><span>K</span>{t.skellSection}</h4>103 <h4><span>K</span>{t.skellSection}</h4>
101 {skell && loadout ? <>104 {skell && loadout ? <>
102 <div className="skell-frame">105 <div className="skell-frame">
103- <small>FRAME</small>106+ <small>{t.skellFrame}</small>
104 <strong>{skell.frame}</strong>107 <strong>{skell.frame}</strong>
105 <span>{localized(strategy.skellRole, lang)}</span>108 <span>{localized(strategy.skellRole, lang)}</span>
106 </div>109 </div>
107 <dl className="skell-facts">110 <dl className="skell-facts">
108- <div><dt>{t.requiredPilotLevel}</dt><dd>LV {skell.requiredLevel}</dd></div>111+ <div><dt>{t.requiredPilotLevel}</dt><dd>{t.levelShort} {skell.requiredLevel}</dd></div>
109- <div><dt>{t.acquisition}</dt><dd>{skell.acquisition}</dd></div>112+ <div><dt>{t.acquisition}</dt><dd>{localizedGameUiText(skell.acquisition, lang)}</dd></div>
110 <div><dt>{t.reason}</dt><dd>{skell.reason}</dd></div>113 <div><dt>{t.reason}</dt><dd>{skell.reason}</dd></div>
111 </dl>114 </dl>
112 {alternatives.length > 0 && <div className="skell-alts">115 {alternatives.length > 0 && <div className="skell-alts">
113 <small>{t.skellAlternatives}</small>116 <small>{t.skellAlternatives}</small>
114 {alternatives.map((alt) => (117 {alternatives.map((alt) => (
115- <p key={alt.frame}><b>{alt.frame}</b><span>{alt.acquisition}</span></p>118+ <p key={alt.frame}><b>{alt.frame}</b><span>{localizedGameUiText(alt.acquisition, lang)}</span></p>
116 ))}119 ))}
117 </div>}120 </div>}
118 <h5>{t.skellWeapons}</h5>121 <h5>{t.skellWeapons}</h5>
@@ -120,7 +123,8 @@ export default function BuildCard({
120 {loadout.weapons.map((weapon) => (123 {loadout.weapons.map((weapon) => (
121 <li key={weapon.name}>124 <li key={weapon.name}>
122 <b>{weapon.name}</b>125 <b>{weapon.name}</b>
123- <small>{weapon.slot} · {weapon.attribute} · {weapon.acquisition}</small>126+ <small>{localizedGameUiText(weapon.slot, lang)} · {localizedGameUiText(weapon.attribute, lang)}</small>
127+ <span className="skell-gear-acquisition">{localizedGameUiText(weapon.acquisition, lang)}</span>
124 </li>128 </li>
125 ))}129 ))}
126 </ul>130 </ul>
modified src/components/TeamBuilder.tsx +1 -0
@@ -53,6 +53,7 @@ export default function TeamBuilder() {
5353 classRanks,
5454 resolvedTeam,
5555 personalRanks,
56+ lang,
5657 );
5758
5859 function handleClassChange(classId: string) {
@@ -53,6 +53,7 @@ export default function TeamBuilder() {
53 classRanks,53 classRanks,
54 resolvedTeam,54 resolvedTeam,
55 personalRanks,55 personalRanks,
56+ lang,
56 );57 );
57 58
58 function handleClassChange(classId: string) {59 function handleClassChange(classId: string) {
modified src/data/i18n.ts +61 -10
@@ -7,6 +7,7 @@ export interface Copy {
77 title: string;
88 subtitle: string;
99 avatarLevel: string;
10+ levelShort: string;
1011 personalRank: string;
1112 personalRankShort: string;
1213 operator: string;
@@ -39,6 +40,7 @@ export interface Copy {
3940 amRecommended: string;
4041 planReasons: string;
4142 skellSection: string;
43+ skellFrame: string;
4244 skellAlternatives: string;
4345 skellWeapons: string;
4446 skellAugments: string;
@@ -62,11 +64,12 @@ export const copy: Record<Lang, Copy> = {
6264 eyebrow: "BLADE // TACTICAL SYSTEM 07",
6365 title: "Team Architect",
6466 subtitle: "Create a sourced, progression-safe general-purpose recommendation for Mira—not a universal best build.",
65- avatarLevel: "Avatar level",
67+ avatarLevel: "Cross level",
68+ levelShort: "LV",
6669 personalRank: "Personal class rank",
6770 personalRankShort: "RANK",
6871 operator: "Companion slot",
69- activeClass: "Avatar active class",
72+ activeClass: "Cross's active class",
7073 rank: "Active class rank",
7174 hierarchy: "Class route",
7275 weapons: "Native weapons",
@@ -80,7 +83,7 @@ export const copy: Record<Lang, Copy> = {
8083 skellLicense: "Has Skell License",
8184 postgameFrames: "Postgame frame schematics",
8285 viewResults: "View live recommendation",
83- resultsHeading: "Progression-safe general-purpose recommendation",
86+ resultsHeading: "All-rounder recommendation",
8487 activeRoute: "ACTIVE ROUTE",
8588 teamMode: "TEAM MODE",
8689 manualPick: "MANUAL",
@@ -95,6 +98,7 @@ export const copy: Record<Lang, Copy> = {
9598 amRecommended: "RECOMMENDED AM",
9699 planReasons: "PLAN REASONS",
97100 skellSection: "SKELL",
101+ skellFrame: "FRAME",
98102 skellAlternatives: "Alternatives",
99103 skellWeapons: "Weapons",
100104 skellAugments: "Augments",
@@ -116,11 +120,12 @@ export const copy: Record<Lang, Copy> = {
116120 eyebrow: "BLADE // SYSTÈME TACTIQUE 07",
117121 title: "Architecte d’équipe",
118122 subtitle: "Créez une recommandation généraliste sourcée et sûre pour votre progression sur Mira — pas un build universellement optimal.",
119- avatarLevel: "Niveau de l’Avatar",
123+ avatarLevel: "Niveau de Cross",
124+ levelShort: "NIV.",
120125 personalRank: "Rang de classe personnel",
121126 personalRankShort: "RANG",
122127 operator: "Emplacement de compagnon",
123- activeClass: "Classe active de l’Avatar",
128+ activeClass: "Classe active de Cross",
124129 rank: "Rang de la classe active",
125130 hierarchy: "Filière de classe",
126131 weapons: "Armes natives",
@@ -132,9 +137,9 @@ export const copy: Record<Lang, Copy> = {
132137 manualState: "MANUEL",
133138 skellProgression: "Progression Skell",
134139 skellLicense: "Licence Skell obtenue",
135- postgameFrames: "Schémas de frames postgame",
140+ postgameFrames: "Schémas de châssis postgame",
136141 viewResults: "Voir la recommandation en direct",
137- resultsHeading: "Recommandation généraliste sûre pour la progression",
142+ resultsHeading: "Recommandation polyvalente",
138143 activeRoute: "FILIÈRE ACTIVE",
139144 teamMode: "MODE D’ÉQUIPE",
140145 manualPick: "MANUEL",
@@ -149,15 +154,16 @@ export const copy: Record<Lang, Copy> = {
149154 amRecommended: "AM RECOMMANDÉ",
150155 planReasons: "RAISONS DU PLAN",
151156 skellSection: "SKELL",
157+ skellFrame: "CHÂSSIS",
152158 skellAlternatives: "Alternatives",
153159 skellWeapons: "Armes",
154160 skellAugments: "Augments",
155161 requiredPilotLevel: "Niveau de pilote requis",
156162 acquisition: "Acquisition",
157163 reason: "Raison",
158- unavailable: "FRAME INDISPONIBLE",
159- skellNoLicense: "Aucune frame n’est recommandée sans la Licence Skell.",
160- skellLowLevel: "Aucune frame n’est recommandée avant le niveau de pilote 20.",
164+ unavailable: "CHÂSSIS INDISPONIBLE",
165+ skellNoLicense: "Aucun châssis n’est recommandé sans la Licence Skell.",
166+ skellLowLevel: "Aucun châssis n’est recommandé avant le niveau de pilote 20.",
161167 methodTitle: "Portée et méthode",
162168 methodBody: "Il s’agit d’une recommandation généraliste sourcée et sûre pour la progression, pas d’un build universellement optimal. Seuls les Arts et Skills appris sont vérifiés selon les rangs de classe ou personnels saisis ; la compatibilité des armes affichées est aussi appliquée. La rencontre, les traits d’équipement, l’inventaire possédé, les Arts d’affinité et signature, ainsi que l’exécution du joueur peuvent changer le meilleur build.",
163169 footerTagline: "Compagnon tactique non officiel",
@@ -166,3 +172,48 @@ export const copy: Record<Lang, Copy> = {
166172 classRanksHint: "Sélectionner une classe maîtrise sa filière parente et remet les autres branches à zéro. Baisser un rang sous 10 remet ses classes filles entraînées à zéro.",
167173 },
168174 };
175+
176+const frenchSkellTerms: Readonly<Record<string, string>> = {
177+ "Complete The Skell License": "Terminer « La licence Skell »",
178+ "Shop purchase": "Achat en boutique",
179+ "Craftable following Chapter 12": "Fabricable après le chapitre 12",
180+ "Craftable following Thinking Makes It So": "Fabricable après « Thinking Makes It So »",
181+ "Complete New Skell on the Block": "Terminer « New Skell on the Block »",
182+ "Craftable following Bozé's Ignorance": "Fabricable après « Bozé’s Ignorance »",
183+ "Craftable following Climbing the Ladder": "Fabricable après « Climbing the Ladder »",
184+ "Complete Chapter 13 (Act 1)": "Terminer le chapitre 13 (acte 1)",
185+ "Craftable following Rapid Misfire": "Fabricable après « Rapid Misfire »",
186+ "Shop purchase / New LA craft": "Achat en boutique / fabrication à New LA",
187+ sidearm: "arme latérale",
188+ back: "dos",
189+ shoulder: "épaule",
190+ arm: "bras",
191+ thermal: "thermique",
192+ physical: "physique",
193+ beam: "laser",
194+ ether: "éther",
195+ gravity: "gravité",
196+ "Melee Attack": "Attaque au corps-à-corps",
197+ "Thermal Attack": "Attaque thermique",
198+ Potential: "Potentiel",
199+ "TP Gain": "Gain de PT",
200+ "Max HP": "PV max",
201+ "Physical Resist": "Résistance physique",
202+ "Debuff Resist": "Résistance aux altérations",
203+ "Melee Accuracy": "Précision au corps-à-corps",
204+ Evasion: "Esquive",
205+ "Ranged Attack": "Attaque à distance",
206+ "Ranged Accuracy": "Précision à distance",
207+ "Electric Attack": "Attaque électrique",
208+ "Appendage Damage": "Dégâts aux appendices",
209+ "Ether Attack": "Attaque Éther",
210+ "Max TP": "PT max",
211+ "Debuff Resistance Down": "Réduction de résistance aux altérations",
212+ "Beam Attack": "Attaque laser",
213+ "Secondary Cooldown": "Délai de recharge secondaire",
214+};
215+
216+/** Localizes display metadata while preserving canonical model, quest, Art and Skill names. */
217+export function localizedGameUiText(value: string, lang: Lang): string {
218+ return lang === "fr" ? frenchSkellTerms[value] ?? value : value;
219+}
@@ -7,6 +7,7 @@ export interface Copy {
7 title: string;7 title: string;
8 subtitle: string;8 subtitle: string;
9 avatarLevel: string;9 avatarLevel: string;
10+ levelShort: string;
10 personalRank: string;11 personalRank: string;
11 personalRankShort: string;12 personalRankShort: string;
12 operator: string;13 operator: string;
@@ -39,6 +40,7 @@ export interface Copy {
39 amRecommended: string;40 amRecommended: string;
40 planReasons: string;41 planReasons: string;
41 skellSection: string;42 skellSection: string;
43+ skellFrame: string;
42 skellAlternatives: string;44 skellAlternatives: string;
43 skellWeapons: string;45 skellWeapons: string;
44 skellAugments: string;46 skellAugments: string;
@@ -62,11 +64,12 @@ export const copy: Record<Lang, Copy> = {
62 eyebrow: "BLADE // TACTICAL SYSTEM 07",64 eyebrow: "BLADE // TACTICAL SYSTEM 07",
63 title: "Team Architect",65 title: "Team Architect",
64 subtitle: "Create a sourced, progression-safe general-purpose recommendation for Mira—not a universal best build.",66 subtitle: "Create a sourced, progression-safe general-purpose recommendation for Mira—not a universal best build.",
65- avatarLevel: "Avatar level",67+ avatarLevel: "Cross level",
68+ levelShort: "LV",
66 personalRank: "Personal class rank",69 personalRank: "Personal class rank",
67 personalRankShort: "RANK",70 personalRankShort: "RANK",
68 operator: "Companion slot",71 operator: "Companion slot",
69- activeClass: "Avatar active class",72+ activeClass: "Cross's active class",
70 rank: "Active class rank",73 rank: "Active class rank",
71 hierarchy: "Class route",74 hierarchy: "Class route",
72 weapons: "Native weapons",75 weapons: "Native weapons",
@@ -80,7 +83,7 @@ export const copy: Record<Lang, Copy> = {
80 skellLicense: "Has Skell License",83 skellLicense: "Has Skell License",
81 postgameFrames: "Postgame frame schematics",84 postgameFrames: "Postgame frame schematics",
82 viewResults: "View live recommendation",85 viewResults: "View live recommendation",
83- resultsHeading: "Progression-safe general-purpose recommendation",86+ resultsHeading: "All-rounder recommendation",
84 activeRoute: "ACTIVE ROUTE",87 activeRoute: "ACTIVE ROUTE",
85 teamMode: "TEAM MODE",88 teamMode: "TEAM MODE",
86 manualPick: "MANUAL",89 manualPick: "MANUAL",
@@ -95,6 +98,7 @@ export const copy: Record<Lang, Copy> = {
95 amRecommended: "RECOMMENDED AM",98 amRecommended: "RECOMMENDED AM",
96 planReasons: "PLAN REASONS",99 planReasons: "PLAN REASONS",
97 skellSection: "SKELL",100 skellSection: "SKELL",
101+ skellFrame: "FRAME",
98 skellAlternatives: "Alternatives",102 skellAlternatives: "Alternatives",
99 skellWeapons: "Weapons",103 skellWeapons: "Weapons",
100 skellAugments: "Augments",104 skellAugments: "Augments",
@@ -116,11 +120,12 @@ export const copy: Record<Lang, Copy> = {
116 eyebrow: "BLADE // SYSTÈME TACTIQUE 07",120 eyebrow: "BLADE // SYSTÈME TACTIQUE 07",
117 title: "Architecte d’équipe",121 title: "Architecte d’équipe",
118 subtitle: "Créez une recommandation généraliste sourcée et sûre pour votre progression sur Mira — pas un build universellement optimal.",122 subtitle: "Créez une recommandation généraliste sourcée et sûre pour votre progression sur Mira — pas un build universellement optimal.",
119- avatarLevel: "Niveau de l’Avatar",123+ avatarLevel: "Niveau de Cross",
124+ levelShort: "NIV.",
120 personalRank: "Rang de classe personnel",125 personalRank: "Rang de classe personnel",
121 personalRankShort: "RANG",126 personalRankShort: "RANG",
122 operator: "Emplacement de compagnon",127 operator: "Emplacement de compagnon",
123- activeClass: "Classe active de l’Avatar",128+ activeClass: "Classe active de Cross",
124 rank: "Rang de la classe active",129 rank: "Rang de la classe active",
125 hierarchy: "Filière de classe",130 hierarchy: "Filière de classe",
126 weapons: "Armes natives",131 weapons: "Armes natives",
@@ -132,9 +137,9 @@ export const copy: Record<Lang, Copy> = {
132 manualState: "MANUEL",137 manualState: "MANUEL",
133 skellProgression: "Progression Skell",138 skellProgression: "Progression Skell",
134 skellLicense: "Licence Skell obtenue",139 skellLicense: "Licence Skell obtenue",
135- postgameFrames: "Schémas de frames postgame",140+ postgameFrames: "Schémas de châssis postgame",
136 viewResults: "Voir la recommandation en direct",141 viewResults: "Voir la recommandation en direct",
137- resultsHeading: "Recommandation généraliste sûre pour la progression",142+ resultsHeading: "Recommandation polyvalente",
138 activeRoute: "FILIÈRE ACTIVE",143 activeRoute: "FILIÈRE ACTIVE",
139 teamMode: "MODE D’ÉQUIPE",144 teamMode: "MODE D’ÉQUIPE",
140 manualPick: "MANUEL",145 manualPick: "MANUEL",
@@ -149,15 +154,16 @@ export const copy: Record<Lang, Copy> = {
149 amRecommended: "AM RECOMMANDÉ",154 amRecommended: "AM RECOMMANDÉ",
150 planReasons: "RAISONS DU PLAN",155 planReasons: "RAISONS DU PLAN",
151 skellSection: "SKELL",156 skellSection: "SKELL",
157+ skellFrame: "CHÂSSIS",
152 skellAlternatives: "Alternatives",158 skellAlternatives: "Alternatives",
153 skellWeapons: "Armes",159 skellWeapons: "Armes",
154 skellAugments: "Augments",160 skellAugments: "Augments",
155 requiredPilotLevel: "Niveau de pilote requis",161 requiredPilotLevel: "Niveau de pilote requis",
156 acquisition: "Acquisition",162 acquisition: "Acquisition",
157 reason: "Raison",163 reason: "Raison",
158- unavailable: "FRAME INDISPONIBLE",164+ unavailable: "CHÂSSIS INDISPONIBLE",
159- skellNoLicense: "Aucune frame n’est recommandée sans la Licence Skell.",165+ skellNoLicense: "Aucun châssis n’est recommandé sans la Licence Skell.",
160- skellLowLevel: "Aucune frame n’est recommandée avant le niveau de pilote 20.",166+ skellLowLevel: "Aucun châssis n’est recommandé avant le niveau de pilote 20.",
161 methodTitle: "Portée et méthode",167 methodTitle: "Portée et méthode",
162 methodBody: "Il s’agit d’une recommandation généraliste sourcée et sûre pour la progression, pas d’un build universellement optimal. Seuls les Arts et Skills appris sont vérifiés selon les rangs de classe ou personnels saisis ; la compatibilité des armes affichées est aussi appliquée. La rencontre, les traits d’équipement, l’inventaire possédé, les Arts d’affinité et signature, ainsi que l’exécution du joueur peuvent changer le meilleur build.",168 methodBody: "Il s’agit d’une recommandation généraliste sourcée et sûre pour la progression, pas d’un build universellement optimal. Seuls les Arts et Skills appris sont vérifiés selon les rangs de classe ou personnels saisis ; la compatibilité des armes affichées est aussi appliquée. La rencontre, les traits d’équipement, l’inventaire possédé, les Arts d’affinité et signature, ainsi que l’exécution du joueur peuvent changer le meilleur build.",
163 footerTagline: "Compagnon tactique non officiel",169 footerTagline: "Compagnon tactique non officiel",
@@ -166,3 +172,48 @@ export const copy: Record<Lang, Copy> = {
166 classRanksHint: "Sélectionner une classe maîtrise sa filière parente et remet les autres branches à zéro. Baisser un rang sous 10 remet ses classes filles entraînées à zéro.",172 classRanksHint: "Sélectionner une classe maîtrise sa filière parente et remet les autres branches à zéro. Baisser un rang sous 10 remet ses classes filles entraînées à zéro.",
167 },173 },
168 };174 };
175+
176+const frenchSkellTerms: Readonly<Record<string, string>> = {
177+ "Complete The Skell License": "Terminer « La licence Skell »",
178+ "Shop purchase": "Achat en boutique",
179+ "Craftable following Chapter 12": "Fabricable après le chapitre 12",
180+ "Craftable following Thinking Makes It So": "Fabricable après « Thinking Makes It So »",
181+ "Complete New Skell on the Block": "Terminer « New Skell on the Block »",
182+ "Craftable following Bozé's Ignorance": "Fabricable après « Bozé’s Ignorance »",
183+ "Craftable following Climbing the Ladder": "Fabricable après « Climbing the Ladder »",
184+ "Complete Chapter 13 (Act 1)": "Terminer le chapitre 13 (acte 1)",
185+ "Craftable following Rapid Misfire": "Fabricable après « Rapid Misfire »",
186+ "Shop purchase / New LA craft": "Achat en boutique / fabrication à New LA",
187+ sidearm: "arme latérale",
188+ back: "dos",
189+ shoulder: "épaule",
190+ arm: "bras",
191+ thermal: "thermique",
192+ physical: "physique",
193+ beam: "laser",
194+ ether: "éther",
195+ gravity: "gravité",
196+ "Melee Attack": "Attaque au corps-à-corps",
197+ "Thermal Attack": "Attaque thermique",
198+ Potential: "Potentiel",
199+ "TP Gain": "Gain de PT",
200+ "Max HP": "PV max",
201+ "Physical Resist": "Résistance physique",
202+ "Debuff Resist": "Résistance aux altérations",
203+ "Melee Accuracy": "Précision au corps-à-corps",
204+ Evasion: "Esquive",
205+ "Ranged Attack": "Attaque à distance",
206+ "Ranged Accuracy": "Précision à distance",
207+ "Electric Attack": "Attaque électrique",
208+ "Appendage Damage": "Dégâts aux appendices",
209+ "Ether Attack": "Attaque Éther",
210+ "Max TP": "PT max",
211+ "Debuff Resistance Down": "Réduction de résistance aux altérations",
212+ "Beam Attack": "Attaque laser",
213+ "Secondary Cooldown": "Délai de recharge secondaire",
214+};
215+
216+/** Localizes display metadata while preserving canonical model, quest, Art and Skill names. */
217+export function localizedGameUiText(value: string, lang: Lang): string {
218+ return lang === "fr" ? frenchSkellTerms[value] ?? value : value;
219+}
modified src/lib/engine.test.ts +13 -14
@@ -151,6 +151,7 @@ test("companion floors and effective levels stay within canonical bounds", () =>
151151 const resolved = resolveTeam(["Lin", "Elma", "Irina"], "duelist", 100);
152152 assert.deepEqual(resolved.slots.map((slot) => slot.effectiveLevel), [99, 99, 99]);
153153 const roster = buildRoster(Number.NaN, "duelist", duelistRoute, resolved, [3, 3, 3]);
154+ assert.equal(roster[0]?.name, "Cross");
154155 assert.equal(roster[0]?.effectiveLevel, 1);
155156 });
156157
@@ -170,7 +171,18 @@ test("Skell frames require license, pilot level, and explicit postgame access",
170171 );
171172 });
172173
173-test("Skell alternatives rank every same-tier frame by strategy effectiveness", () => {
174+test("Skell recommendations localize explanatory text in French", () => {
175+ const frame = recommendSkellFrame({
176+ strategy: "galactic",
177+ pilotLevel: 30,
178+ hasLicense: true,
179+ lang: "fr",
180+ });
181+
182+ assert.equal(frame?.reason, "Verus est le meilleur châssis de boutique éligible pour Burst laser au corps-à-corps");
183+});
184+
185+test("Skell alternatives retain the five most effective same-tier frames", () => {
174186 assert.deepEqual(
175187 recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }),
176188 [],
@@ -187,10 +199,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness",
187199 "AS0430 Lailah ST",
188200 "XS0630 Mastema ST",
189201 "US0230 Urban ST",
190- "AS0530 Inferno ST",
191- "GD0930 Hraesvelg ST",
192- "AS0330 Verus ST",
193- "AS0130 Formula ST",
194202 ]);
195203
196204 const tier50 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 60, hasLicense: true });
@@ -200,10 +208,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness",
200208 "XS0650 Mastema ST",
201209 "AS0450 Lailah ST",
202210 "AS0550 Inferno ST",
203- "US0250 Urban ST",
204- "AS0350 Verus ST",
205- "GD0950 Hraesvelg ST",
206- "AS0150 Formula ST",
207211 ]);
208212
209213 const postgame = recommendSkellAlternatives({
@@ -215,11 +219,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness",
215219 "Ares 70",
216220 "Lailah Queen",
217221 "Mastema White Reaper",
218- "Urban Lincoln",
219- "Inferno Skydon",
220- "Hraesvelg Lord",
221- "Verus Cain",
222- "Formula Zero",
223222 ]);
224223 });
225224
@@ -151,6 +151,7 @@ test("companion floors and effective levels stay within canonical bounds", () =>
151 const resolved = resolveTeam(["Lin", "Elma", "Irina"], "duelist", 100);151 const resolved = resolveTeam(["Lin", "Elma", "Irina"], "duelist", 100);
152 assert.deepEqual(resolved.slots.map((slot) => slot.effectiveLevel), [99, 99, 99]);152 assert.deepEqual(resolved.slots.map((slot) => slot.effectiveLevel), [99, 99, 99]);
153 const roster = buildRoster(Number.NaN, "duelist", duelistRoute, resolved, [3, 3, 3]);153 const roster = buildRoster(Number.NaN, "duelist", duelistRoute, resolved, [3, 3, 3]);
154+ assert.equal(roster[0]?.name, "Cross");
154 assert.equal(roster[0]?.effectiveLevel, 1);155 assert.equal(roster[0]?.effectiveLevel, 1);
155 });156 });
156 157
@@ -170,7 +171,18 @@ test("Skell frames require license, pilot level, and explicit postgame access",
170 );171 );
171 });172 });
172 173
173-test("Skell alternatives rank every same-tier frame by strategy effectiveness", () => {174+test("Skell recommendations localize explanatory text in French", () => {
175+ const frame = recommendSkellFrame({
176+ strategy: "galactic",
177+ pilotLevel: 30,
178+ hasLicense: true,
179+ lang: "fr",
180+ });
181+
182+ assert.equal(frame?.reason, "Verus est le meilleur châssis de boutique éligible pour Burst laser au corps-à-corps");
183+});
184+
185+test("Skell alternatives retain the five most effective same-tier frames", () => {
174 assert.deepEqual(186 assert.deepEqual(
175 recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }),187 recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 50, hasLicense: false }),
176 [],188 [],
@@ -187,10 +199,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness",
187 "AS0430 Lailah ST",199 "AS0430 Lailah ST",
188 "XS0630 Mastema ST",200 "XS0630 Mastema ST",
189 "US0230 Urban ST",201 "US0230 Urban ST",
190- "AS0530 Inferno ST",
191- "GD0930 Hraesvelg ST",
192- "AS0330 Verus ST",
193- "AS0130 Formula ST",
194 ]);202 ]);
195 203
196 const tier50 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 60, hasLicense: true });204 const tier50 = recommendSkellAlternatives({ strategy: "duelist", pilotLevel: 60, hasLicense: true });
@@ -200,10 +208,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness",
200 "XS0650 Mastema ST",208 "XS0650 Mastema ST",
201 "AS0450 Lailah ST",209 "AS0450 Lailah ST",
202 "AS0550 Inferno ST",210 "AS0550 Inferno ST",
203- "US0250 Urban ST",
204- "AS0350 Verus ST",
205- "GD0950 Hraesvelg ST",
206- "AS0150 Formula ST",
207 ]);211 ]);
208 212
209 const postgame = recommendSkellAlternatives({213 const postgame = recommendSkellAlternatives({
@@ -215,11 +219,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness",
215 "Ares 70",219 "Ares 70",
216 "Lailah Queen",220 "Lailah Queen",
217 "Mastema White Reaper",221 "Mastema White Reaper",
218- "Urban Lincoln",
219- "Inferno Skydon",
220- "Hraesvelg Lord",
221- "Verus Cain",
222- "Formula Zero",
223 ]);222 ]);
224 });223 });
225 224
modified src/lib/engine.ts +94 -29
@@ -77,6 +77,7 @@ export interface SkellRecommendationRequest {
7777 pilotLevel: number;
7878 hasLicense: boolean;
7979 postgameFrames?: boolean;
80+ lang?: Lang;
8081 }
8182
8283 function compareText(left: string, right: string): number {
@@ -115,6 +116,10 @@ export function localized(pair: LocalizedText, lang: Lang): string {
115116 return lang === "en" ? pair[0] : pair[1];
116117 }
117118
119+function translated(lang: Lang, english: string, french: string): string {
120+ return lang === "fr" ? french : english;
121+}
122+
118123 /** Walks the class tree from Drifter down to the selected class. */
119124 export function classRoute(selected: ClassEntry): ClassEntry[] {
120125 const chain: ClassEntry[] = [];
@@ -303,6 +308,7 @@ function selectAvatarArts(
303308 learnedArts: readonly LearnedArt[],
304309 weapons: WeaponPair,
305310 preferences: readonly string[],
311+ lang: Lang,
306312 ): ArtSelection[] {
307313 return learnedArts
308314 .filter((art) => art.weapon === weapons.ranged || art.weapon === weapons.melee)
@@ -325,8 +331,16 @@ function selectAvatarArts(
325331 citation: art.source,
326332 },
327333 reason: preferredAt < preferences.length
328- ? `Strategy preference #${preferredAt + 1}; usable with ${art.weapon}`
329- : `Usable with ${art.weapon}; learned at ${art.sourceClassId} rank ${art.rank}`,
334+ ? translated(
335+ lang,
336+ `Strategy preference #${preferredAt + 1}; usable with ${art.weapon}`,
337+ `Préférence stratégique n°${preferredAt + 1} ; utilisable avec ${art.weapon}`,
338+ )
339+ : translated(
340+ lang,
341+ `Usable with ${art.weapon}; learned at ${art.sourceClassId} rank ${art.rank}`,
342+ `Utilisable avec ${art.weapon} ; appris au rang ${art.rank} de ${art.sourceClassId}`,
343+ ),
330344 };
331345 });
332346 }
@@ -335,6 +349,7 @@ function selectAvatarSkills(
335349 learnedSkills: readonly LearnedSkill[],
336350 preferences: readonly string[],
337351 skillSlots: number,
352+ lang: Lang,
338353 ): SkillSelection[] {
339354 return [...learnedSkills]
340355 .sort((left, right) =>
@@ -355,8 +370,16 @@ function selectAvatarSkills(
355370 citation: skill.source,
356371 },
357372 reason: preferredAt < preferences.length
358- ? `Strategy preference #${preferredAt + 1}; cross-class once learned`
359- : `Learned at ${skill.sourceClassId} rank ${skill.rank}; usable cross-class`,
373+ ? translated(
374+ lang,
375+ `Strategy preference #${preferredAt + 1}; cross-class once learned`,
376+ `Préférence stratégique n°${preferredAt + 1} ; utilisable dans les autres classes après apprentissage`,
377+ )
378+ : translated(
379+ lang,
380+ `Learned at ${skill.sourceClassId} rank ${skill.rank}; usable cross-class`,
381+ `Appris au rang ${skill.rank} de ${skill.sourceClassId} ; utilisable dans les autres classes`,
382+ ),
360383 };
361384 });
362385 }
@@ -369,6 +392,7 @@ export function planAvatar(
369392 activeClassId: string,
370393 classRanks: ClassRanks,
371394 strategy: StrategyKey = classStrategy[activeClassId],
395+ lang: Lang = "en",
372396 ): AvatarPlan {
373397 const activeClass = classById.get(activeClassId);
374398 const progression = classProgressionById.get(activeClassId);
@@ -391,11 +415,12 @@ export function planAvatar(
391415 || compareText(left.pair.ranged, right.pair.ranged)
392416 || compareText(left.pair.melee, right.pair.melee));
393417 const weapons = rankedPairs[0]?.pair ?? nativePair;
394- const arts = selectAvatarArts(learned.arts, weapons, strategyData.preferredArts);
418+ const arts = selectAvatarArts(learned.arts, weapons, strategyData.preferredArts, lang);
395419 const skills = selectAvatarSkills(
396420 learned.skills,
397421 strategyData.preferredSkills,
398422 progression.skillSlots,
423+ lang,
399424 );
400425 const masteredTierFour = tierFourClasses
401426 .filter((entry) => normalizedClassRanks[entry.id] === 10)
@@ -403,7 +428,7 @@ export function planAvatar(
403428
404429 return {
405430 activeClassId,
406- activeClassName: activeClass.en,
431+ activeClassName: activeClass[lang],
407432 activeRank: normalizedClassRanks[activeClassId] ?? 0,
408433 normalizedClassRanks,
409434 strategy,
@@ -413,11 +438,23 @@ export function planAvatar(
413438 skills,
414439 skillSlots: progression.skillSlots,
415440 planReasons: [
416- `${arts.length} Arts match ${weapons.ranged} + ${weapons.melee}; capped at 8`,
417- `${skills.length} learned Skills fill the active class cap of ${progression.skillSlots}`,
441+ translated(
442+ lang,
443+ `${arts.length} Arts match ${weapons.ranged} + ${weapons.melee}; capped at 8`,
444+ `${arts.length} Arts correspondent à ${weapons.ranged} + ${weapons.melee} ; maximum de 8`,
445+ ),
446+ translated(
447+ lang,
448+ `${skills.length} learned Skills fill the active class cap of ${progression.skillSlots}`,
449+ `${skills.length} Skills appris remplissent la limite de ${progression.skillSlots} de la classe active`,
450+ ),
418451 masteredTierFour.length > 0
419- ? `Mastered tier-4 weapon access: ${masteredTierFour.join(", ")}`
420- : "No mastered tier-4 cross-class weapons",
452+ ? translated(
453+ lang,
454+ `Mastered tier-4 weapon access: ${masteredTierFour.join(", ")}`,
455+ `Accès aux armes interclasses de rang 4 maîtrisées : ${masteredTierFour.join(", ")}`,
456+ )
457+ : translated(lang, "No mastered tier-4 cross-class weapons", "Aucune arme interclasse de rang 4 maîtrisée"),
421458 ],
422459 };
423460 }
@@ -425,6 +462,7 @@ export function planAvatar(
425462 function selectCompanionArts(
426463 companion: (typeof companions)[number],
427464 personalRank: number,
465+ lang: Lang,
428466 ): ArtSelection[] {
429467 const strategy = buildStrategies[companion.strategy];
430468 return companion.arts
@@ -451,10 +489,10 @@ function selectCompanionArts(
451489 citation: companion.source,
452490 },
453491 reason: defaultAt < companion.defaultArts.length
454- ? `Canonical default Art #${defaultAt + 1}`
492+ ? translated(lang, `Canonical default Art #${defaultAt + 1}`, `Art par défaut canonique n°${defaultAt + 1}`)
455493 : preferredAt < strategy.preferredArts.length
456- ? `Strategy preference #${preferredAt + 1}`
457- : `Unlocked at personal rank ${art.rank}`,
494+ ? translated(lang, `Strategy preference #${preferredAt + 1}`, `Préférence stratégique n°${preferredAt + 1}`)
495+ : translated(lang, `Unlocked at personal rank ${art.rank}`, `Débloqué au rang personnel ${art.rank}`),
458496 };
459497 });
460498 }
@@ -462,6 +500,7 @@ function selectCompanionArts(
462500 function selectCompanionSkills(
463501 companion: (typeof companions)[number],
464502 personalRank: number,
503+ lang: Lang,
465504 ): SkillSelection[] {
466505 const preferences = buildStrategies[companion.strategy].preferredSkills;
467506 return companion.skills
@@ -484,8 +523,8 @@ function selectCompanionSkills(
484523 citation: companion.source,
485524 },
486525 reason: preferredAt < preferences.length
487- ? `Strategy preference #${preferredAt + 1}`
488- : `Unlocked at personal rank ${skill.rank}`,
526+ ? translated(lang, `Strategy preference #${preferredAt + 1}`, `Préférence stratégique n°${preferredAt + 1}`)
527+ : translated(lang, `Unlocked at personal rank ${skill.rank}`, `Débloqué au rang personnel ${skill.rank}`),
489528 };
490529 });
491530 }
@@ -495,6 +534,7 @@ export function planCompanion(
495534 name: string,
496535 effectiveLevel: number,
497536 personalClassRank: number,
537+ lang: Lang = "en",
498538 ): CompanionPlan {
499539 const companion = companionByName.get(name);
500540 if (!companion) throw new Error(`Unknown companion: ${name}`);
@@ -503,8 +543,8 @@ export function planCompanion(
503543 companion.startRank,
504544 clampRank(personalClassRank, companion.maxRank),
505545 );
506- const arts = selectCompanionArts(companion, personalRank);
507- const skills = selectCompanionSkills(companion, personalRank);
546+ const arts = selectCompanionArts(companion, personalRank, lang);
547+ const skills = selectCompanionSkills(companion, personalRank, lang);
508548 return {
509549 name: companion.name,
510550 className: companion.className,
@@ -519,8 +559,16 @@ export function planCompanion(
519559 note: buildStrategies[companion.strategy].role,
520560 source: companion.source,
521561 planReasons: [
522- `Personal rank ${personalRank}: ${arts.length} learned Arts and ${skills.length}/${companion.skillSlots} Skills`,
523- "Canonical default Arts precede strategy preferences",
562+ translated(
563+ lang,
564+ `Personal rank ${personalRank}: ${arts.length} learned Arts and ${skills.length}/${companion.skillSlots} Skills`,
565+ `Rang personnel ${personalRank} : ${arts.length} Arts appris et ${skills.length}/${companion.skillSlots} Skills`,
566+ ),
567+ translated(
568+ lang,
569+ "Canonical default Arts precede strategy preferences",
570+ "Les Arts par défaut canoniques priment sur les préférences stratégiques",
571+ ),
524572 ],
525573 };
526574 }
@@ -703,12 +751,13 @@ export function buildRoster(
703751 classRanks: ClassRanks,
704752 team: TeamResolution,
705753 personalClassRanks: readonly [number, number, number],
754+ lang: Lang = "en",
706755 ): RosterMember[] {
707- const avatar = planAvatar(activeClassId, classRanks);
756+ const avatar = planAvatar(activeClassId, classRanks, undefined, lang);
708757 const strategyNote = buildStrategies[avatar.strategy].role;
709758 const roster: RosterMember[] = [{
710759 kind: "avatar",
711- name: "Avatar",
760+ name: "Cross",
712761 className: avatar.activeClassName,
713762 effectiveLevel: clampLevel(avatarLevel),
714763 rank: avatar.activeRank,
@@ -728,6 +777,7 @@ export function buildRoster(
728777 slot.name,
729778 slot.effectiveLevel,
730779 personalClassRanks[index],
780+ lang,
731781 );
732782 roster.push({
733783 kind: "companion",
@@ -753,23 +803,28 @@ export function buildRoster(
753803 export function recommendSkellFrame(
754804 request: SkellRecommendationRequest,
755805 ): SkellFrameRecommendation | null {
806+ const lang = request.lang ?? "en";
756807 const pilotLevel = clampLevel(request.pilotLevel);
757808 if (!request.hasLicense || pilotLevel < 20) return null;
758809
759810 if (pilotLevel < 30) {
760811 return {
761812 ...licenseFrame,
762- reason: "Guaranteed license frame; no strategy-specific upgrade assumed",
813+ reason: lang === "fr"
814+ ? "Châssis garanti par la licence ; aucune amélioration spécifique à la stratégie n’est supposée"
815+ : "Guaranteed license frame; no strategy-specific upgrade assumed",
763816 source: skellSource,
764817 };
765818 }
766819
767820 const family = strategyFrameFamily[request.strategy];
768- const strategyRole = buildStrategies[request.strategy].role[0];
821+ const strategyRole = localized(buildStrategies[request.strategy].role, lang);
769822 if (pilotLevel >= 60 && request.postgameFrames === true) {
770823 return {
771824 ...postgameFrames[family],
772- reason: `Explicit postgame-frame choice; ${family} suits ${strategyRole}`,
825+ reason: lang === "fr"
826+ ? `Choix explicite d’un châssis postgame ; ${family} convient à ${strategyRole}`
827+ : `Explicit postgame-frame choice; ${family} suits ${strategyRole}`,
773828 source: skellSource,
774829 };
775830 }
@@ -777,7 +832,9 @@ export function recommendSkellFrame(
777832 const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30;
778833 return {
779834 ...shopFrames[tier][family],
780- reason: `${family} is the highest eligible shop frame for ${strategyRole}`,
835+ reason: lang === "fr"
836+ ? `${family} est le meilleur châssis de boutique éligible pour ${strategyRole}`
837+ : `${family} is the highest eligible shop frame for ${strategyRole}`,
781838 source: skellSource,
782839 };
783840 }
@@ -791,6 +848,7 @@ export function recommendSkellFrame(
791848 export function recommendSkellAlternatives(
792849 request: SkellRecommendationRequest,
793850 ): readonly SkellFrameRecommendation[] {
851+ const lang = request.lang ?? "en";
794852 const pilotLevel = clampLevel(request.pilotLevel);
795853 if (!request.hasLicense || pilotLevel < 20) return [];
796854
@@ -823,16 +881,23 @@ export function recommendSkellAlternatives(
823881 .map((alt) => ({
824882 ...alt,
825883 reason: alt.prerequisite
826- ? `Quest-locked frame at the same tier (${alt.prerequisite})`
884+ ? lang === "fr"
885+ ? `Châssis verrouillé par une quête au même palier (${alt.prerequisite})`
886+ : `Quest-locked frame at the same tier (${alt.prerequisite})`
827887 : postgame
828- ? "Postgame craftable frame at the same tier"
829- : "Alternative shop frame at the same tier",
888+ ? lang === "fr"
889+ ? "Châssis fabricable postgame au même palier"
890+ : "Postgame craftable frame at the same tier"
891+ : lang === "fr"
892+ ? "Châssis de boutique alternatif au même palier"
893+ : "Alternative shop frame at the same tier",
830894 source: skellSource,
831895 }))
832896 .sort((left, right) =>
833897 effectiveness(right.frame) - effectiveness(left.frame)
834898 || compareText(left.frame, right.frame),
835- );
899+ )
900+ .slice(0, 5);
836901 }
837902
838903 /**
@@ -77,6 +77,7 @@ export interface SkellRecommendationRequest {
77 pilotLevel: number;77 pilotLevel: number;
78 hasLicense: boolean;78 hasLicense: boolean;
79 postgameFrames?: boolean;79 postgameFrames?: boolean;
80+ lang?: Lang;
80 }81 }
81 82
82 function compareText(left: string, right: string): number {83 function compareText(left: string, right: string): number {
@@ -115,6 +116,10 @@ export function localized(pair: LocalizedText, lang: Lang): string {
115 return lang === "en" ? pair[0] : pair[1];116 return lang === "en" ? pair[0] : pair[1];
116 }117 }
117 118
119+function translated(lang: Lang, english: string, french: string): string {
120+ return lang === "fr" ? french : english;
121+}
122+
118 /** Walks the class tree from Drifter down to the selected class. */123 /** Walks the class tree from Drifter down to the selected class. */
119 export function classRoute(selected: ClassEntry): ClassEntry[] {124 export function classRoute(selected: ClassEntry): ClassEntry[] {
120 const chain: ClassEntry[] = [];125 const chain: ClassEntry[] = [];
@@ -303,6 +308,7 @@ function selectAvatarArts(
303 learnedArts: readonly LearnedArt[],308 learnedArts: readonly LearnedArt[],
304 weapons: WeaponPair,309 weapons: WeaponPair,
305 preferences: readonly string[],310 preferences: readonly string[],
311+ lang: Lang,
306 ): ArtSelection[] {312 ): ArtSelection[] {
307 return learnedArts313 return learnedArts
308 .filter((art) => art.weapon === weapons.ranged || art.weapon === weapons.melee)314 .filter((art) => art.weapon === weapons.ranged || art.weapon === weapons.melee)
@@ -325,8 +331,16 @@ function selectAvatarArts(
325 citation: art.source,331 citation: art.source,
326 },332 },
327 reason: preferredAt < preferences.length333 reason: preferredAt < preferences.length
328- ? `Strategy preference #${preferredAt + 1}; usable with ${art.weapon}`334+ ? translated(
329- : `Usable with ${art.weapon}; learned at ${art.sourceClassId} rank ${art.rank}`,335+ lang,
336+ `Strategy preference #${preferredAt + 1}; usable with ${art.weapon}`,
337+ `Préférence stratégique n°${preferredAt + 1} ; utilisable avec ${art.weapon}`,
338+ )
339+ : translated(
340+ lang,
341+ `Usable with ${art.weapon}; learned at ${art.sourceClassId} rank ${art.rank}`,
342+ `Utilisable avec ${art.weapon} ; appris au rang ${art.rank} de ${art.sourceClassId}`,
343+ ),
330 };344 };
331 });345 });
332 }346 }
@@ -335,6 +349,7 @@ function selectAvatarSkills(
335 learnedSkills: readonly LearnedSkill[],349 learnedSkills: readonly LearnedSkill[],
336 preferences: readonly string[],350 preferences: readonly string[],
337 skillSlots: number,351 skillSlots: number,
352+ lang: Lang,
338 ): SkillSelection[] {353 ): SkillSelection[] {
339 return [...learnedSkills]354 return [...learnedSkills]
340 .sort((left, right) =>355 .sort((left, right) =>
@@ -355,8 +370,16 @@ function selectAvatarSkills(
355 citation: skill.source,370 citation: skill.source,
356 },371 },
357 reason: preferredAt < preferences.length372 reason: preferredAt < preferences.length
358- ? `Strategy preference #${preferredAt + 1}; cross-class once learned`373+ ? translated(
359- : `Learned at ${skill.sourceClassId} rank ${skill.rank}; usable cross-class`,374+ lang,
375+ `Strategy preference #${preferredAt + 1}; cross-class once learned`,
376+ `Préférence stratégique n°${preferredAt + 1} ; utilisable dans les autres classes après apprentissage`,
377+ )
378+ : translated(
379+ lang,
380+ `Learned at ${skill.sourceClassId} rank ${skill.rank}; usable cross-class`,
381+ `Appris au rang ${skill.rank} de ${skill.sourceClassId} ; utilisable dans les autres classes`,
382+ ),
360 };383 };
361 });384 });
362 }385 }
@@ -369,6 +392,7 @@ export function planAvatar(
369 activeClassId: string,392 activeClassId: string,
370 classRanks: ClassRanks,393 classRanks: ClassRanks,
371 strategy: StrategyKey = classStrategy[activeClassId],394 strategy: StrategyKey = classStrategy[activeClassId],
395+ lang: Lang = "en",
372 ): AvatarPlan {396 ): AvatarPlan {
373 const activeClass = classById.get(activeClassId);397 const activeClass = classById.get(activeClassId);
374 const progression = classProgressionById.get(activeClassId);398 const progression = classProgressionById.get(activeClassId);
@@ -391,11 +415,12 @@ export function planAvatar(
391 || compareText(left.pair.ranged, right.pair.ranged)415 || compareText(left.pair.ranged, right.pair.ranged)
392 || compareText(left.pair.melee, right.pair.melee));416 || compareText(left.pair.melee, right.pair.melee));
393 const weapons = rankedPairs[0]?.pair ?? nativePair;417 const weapons = rankedPairs[0]?.pair ?? nativePair;
394- const arts = selectAvatarArts(learned.arts, weapons, strategyData.preferredArts);418+ const arts = selectAvatarArts(learned.arts, weapons, strategyData.preferredArts, lang);
395 const skills = selectAvatarSkills(419 const skills = selectAvatarSkills(
396 learned.skills,420 learned.skills,
397 strategyData.preferredSkills,421 strategyData.preferredSkills,
398 progression.skillSlots,422 progression.skillSlots,
423+ lang,
399 );424 );
400 const masteredTierFour = tierFourClasses425 const masteredTierFour = tierFourClasses
401 .filter((entry) => normalizedClassRanks[entry.id] === 10)426 .filter((entry) => normalizedClassRanks[entry.id] === 10)
@@ -403,7 +428,7 @@ export function planAvatar(
403 428
404 return {429 return {
405 activeClassId,430 activeClassId,
406- activeClassName: activeClass.en,431+ activeClassName: activeClass[lang],
407 activeRank: normalizedClassRanks[activeClassId] ?? 0,432 activeRank: normalizedClassRanks[activeClassId] ?? 0,
408 normalizedClassRanks,433 normalizedClassRanks,
409 strategy,434 strategy,
@@ -413,11 +438,23 @@ export function planAvatar(
413 skills,438 skills,
414 skillSlots: progression.skillSlots,439 skillSlots: progression.skillSlots,
415 planReasons: [440 planReasons: [
416- `${arts.length} Arts match ${weapons.ranged} + ${weapons.melee}; capped at 8`,441+ translated(
417- `${skills.length} learned Skills fill the active class cap of ${progression.skillSlots}`,442+ lang,
443+ `${arts.length} Arts match ${weapons.ranged} + ${weapons.melee}; capped at 8`,
444+ `${arts.length} Arts correspondent à ${weapons.ranged} + ${weapons.melee} ; maximum de 8`,
445+ ),
446+ translated(
447+ lang,
448+ `${skills.length} learned Skills fill the active class cap of ${progression.skillSlots}`,
449+ `${skills.length} Skills appris remplissent la limite de ${progression.skillSlots} de la classe active`,
450+ ),
418 masteredTierFour.length > 0451 masteredTierFour.length > 0
419- ? `Mastered tier-4 weapon access: ${masteredTierFour.join(", ")}`452+ ? translated(
420- : "No mastered tier-4 cross-class weapons",453+ lang,
454+ `Mastered tier-4 weapon access: ${masteredTierFour.join(", ")}`,
455+ `Accès aux armes interclasses de rang 4 maîtrisées : ${masteredTierFour.join(", ")}`,
456+ )
457+ : translated(lang, "No mastered tier-4 cross-class weapons", "Aucune arme interclasse de rang 4 maîtrisée"),
421 ],458 ],
422 };459 };
423 }460 }
@@ -425,6 +462,7 @@ export function planAvatar(
425 function selectCompanionArts(462 function selectCompanionArts(
426 companion: (typeof companions)[number],463 companion: (typeof companions)[number],
427 personalRank: number,464 personalRank: number,
465+ lang: Lang,
428 ): ArtSelection[] {466 ): ArtSelection[] {
429 const strategy = buildStrategies[companion.strategy];467 const strategy = buildStrategies[companion.strategy];
430 return companion.arts468 return companion.arts
@@ -451,10 +489,10 @@ function selectCompanionArts(
451 citation: companion.source,489 citation: companion.source,
452 },490 },
453 reason: defaultAt < companion.defaultArts.length491 reason: defaultAt < companion.defaultArts.length
454- ? `Canonical default Art #${defaultAt + 1}`492+ ? translated(lang, `Canonical default Art #${defaultAt + 1}`, `Art par défaut canonique n°${defaultAt + 1}`)
455 : preferredAt < strategy.preferredArts.length493 : preferredAt < strategy.preferredArts.length
456- ? `Strategy preference #${preferredAt + 1}`494+ ? translated(lang, `Strategy preference #${preferredAt + 1}`, `Préférence stratégique n°${preferredAt + 1}`)
457- : `Unlocked at personal rank ${art.rank}`,495+ : translated(lang, `Unlocked at personal rank ${art.rank}`, `Débloqué au rang personnel ${art.rank}`),
458 };496 };
459 });497 });
460 }498 }
@@ -462,6 +500,7 @@ function selectCompanionArts(
462 function selectCompanionSkills(500 function selectCompanionSkills(
463 companion: (typeof companions)[number],501 companion: (typeof companions)[number],
464 personalRank: number,502 personalRank: number,
503+ lang: Lang,
465 ): SkillSelection[] {504 ): SkillSelection[] {
466 const preferences = buildStrategies[companion.strategy].preferredSkills;505 const preferences = buildStrategies[companion.strategy].preferredSkills;
467 return companion.skills506 return companion.skills
@@ -484,8 +523,8 @@ function selectCompanionSkills(
484 citation: companion.source,523 citation: companion.source,
485 },524 },
486 reason: preferredAt < preferences.length525 reason: preferredAt < preferences.length
487- ? `Strategy preference #${preferredAt + 1}`526+ ? translated(lang, `Strategy preference #${preferredAt + 1}`, `Préférence stratégique n°${preferredAt + 1}`)
488- : `Unlocked at personal rank ${skill.rank}`,527+ : translated(lang, `Unlocked at personal rank ${skill.rank}`, `Débloqué au rang personnel ${skill.rank}`),
489 };528 };
490 });529 });
491 }530 }
@@ -495,6 +534,7 @@ export function planCompanion(
495 name: string,534 name: string,
496 effectiveLevel: number,535 effectiveLevel: number,
497 personalClassRank: number,536 personalClassRank: number,
537+ lang: Lang = "en",
498 ): CompanionPlan {538 ): CompanionPlan {
499 const companion = companionByName.get(name);539 const companion = companionByName.get(name);
500 if (!companion) throw new Error(`Unknown companion: ${name}`);540 if (!companion) throw new Error(`Unknown companion: ${name}`);
@@ -503,8 +543,8 @@ export function planCompanion(
503 companion.startRank,543 companion.startRank,
504 clampRank(personalClassRank, companion.maxRank),544 clampRank(personalClassRank, companion.maxRank),
505 );545 );
506- const arts = selectCompanionArts(companion, personalRank);546+ const arts = selectCompanionArts(companion, personalRank, lang);
507- const skills = selectCompanionSkills(companion, personalRank);547+ const skills = selectCompanionSkills(companion, personalRank, lang);
508 return {548 return {
509 name: companion.name,549 name: companion.name,
510 className: companion.className,550 className: companion.className,
@@ -519,8 +559,16 @@ export function planCompanion(
519 note: buildStrategies[companion.strategy].role,559 note: buildStrategies[companion.strategy].role,
520 source: companion.source,560 source: companion.source,
521 planReasons: [561 planReasons: [
522- `Personal rank ${personalRank}: ${arts.length} learned Arts and ${skills.length}/${companion.skillSlots} Skills`,562+ translated(
523- "Canonical default Arts precede strategy preferences",563+ lang,
564+ `Personal rank ${personalRank}: ${arts.length} learned Arts and ${skills.length}/${companion.skillSlots} Skills`,
565+ `Rang personnel ${personalRank} : ${arts.length} Arts appris et ${skills.length}/${companion.skillSlots} Skills`,
566+ ),
567+ translated(
568+ lang,
569+ "Canonical default Arts precede strategy preferences",
570+ "Les Arts par défaut canoniques priment sur les préférences stratégiques",
571+ ),
524 ],572 ],
525 };573 };
526 }574 }
@@ -703,12 +751,13 @@ export function buildRoster(
703 classRanks: ClassRanks,751 classRanks: ClassRanks,
704 team: TeamResolution,752 team: TeamResolution,
705 personalClassRanks: readonly [number, number, number],753 personalClassRanks: readonly [number, number, number],
754+ lang: Lang = "en",
706 ): RosterMember[] {755 ): RosterMember[] {
707- const avatar = planAvatar(activeClassId, classRanks);756+ const avatar = planAvatar(activeClassId, classRanks, undefined, lang);
708 const strategyNote = buildStrategies[avatar.strategy].role;757 const strategyNote = buildStrategies[avatar.strategy].role;
709 const roster: RosterMember[] = [{758 const roster: RosterMember[] = [{
710 kind: "avatar",759 kind: "avatar",
711- name: "Avatar",760+ name: "Cross",
712 className: avatar.activeClassName,761 className: avatar.activeClassName,
713 effectiveLevel: clampLevel(avatarLevel),762 effectiveLevel: clampLevel(avatarLevel),
714 rank: avatar.activeRank,763 rank: avatar.activeRank,
@@ -728,6 +777,7 @@ export function buildRoster(
728 slot.name,777 slot.name,
729 slot.effectiveLevel,778 slot.effectiveLevel,
730 personalClassRanks[index],779 personalClassRanks[index],
780+ lang,
731 );781 );
732 roster.push({782 roster.push({
733 kind: "companion",783 kind: "companion",
@@ -753,23 +803,28 @@ export function buildRoster(
753 export function recommendSkellFrame(803 export function recommendSkellFrame(
754 request: SkellRecommendationRequest,804 request: SkellRecommendationRequest,
755 ): SkellFrameRecommendation | null {805 ): SkellFrameRecommendation | null {
806+ const lang = request.lang ?? "en";
756 const pilotLevel = clampLevel(request.pilotLevel);807 const pilotLevel = clampLevel(request.pilotLevel);
757 if (!request.hasLicense || pilotLevel < 20) return null;808 if (!request.hasLicense || pilotLevel < 20) return null;
758 809
759 if (pilotLevel < 30) {810 if (pilotLevel < 30) {
760 return {811 return {
761 ...licenseFrame,812 ...licenseFrame,
762- reason: "Guaranteed license frame; no strategy-specific upgrade assumed",813+ reason: lang === "fr"
814+ ? "Châssis garanti par la licence ; aucune amélioration spécifique à la stratégie n’est supposée"
815+ : "Guaranteed license frame; no strategy-specific upgrade assumed",
763 source: skellSource,816 source: skellSource,
764 };817 };
765 }818 }
766 819
767 const family = strategyFrameFamily[request.strategy];820 const family = strategyFrameFamily[request.strategy];
768- const strategyRole = buildStrategies[request.strategy].role[0];821+ const strategyRole = localized(buildStrategies[request.strategy].role, lang);
769 if (pilotLevel >= 60 && request.postgameFrames === true) {822 if (pilotLevel >= 60 && request.postgameFrames === true) {
770 return {823 return {
771 ...postgameFrames[family],824 ...postgameFrames[family],
772- reason: `Explicit postgame-frame choice; ${family} suits ${strategyRole}`,825+ reason: lang === "fr"
826+ ? `Choix explicite d’un châssis postgame ; ${family} convient à ${strategyRole}`
827+ : `Explicit postgame-frame choice; ${family} suits ${strategyRole}`,
773 source: skellSource,828 source: skellSource,
774 };829 };
775 }830 }
@@ -777,7 +832,9 @@ export function recommendSkellFrame(
777 const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30;832 const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30;
778 return {833 return {
779 ...shopFrames[tier][family],834 ...shopFrames[tier][family],
780- reason: `${family} is the highest eligible shop frame for ${strategyRole}`,835+ reason: lang === "fr"
836+ ? `${family} est le meilleur châssis de boutique éligible pour ${strategyRole}`
837+ : `${family} is the highest eligible shop frame for ${strategyRole}`,
781 source: skellSource,838 source: skellSource,
782 };839 };
783 }840 }
@@ -791,6 +848,7 @@ export function recommendSkellFrame(
791 export function recommendSkellAlternatives(848 export function recommendSkellAlternatives(
792 request: SkellRecommendationRequest,849 request: SkellRecommendationRequest,
793 ): readonly SkellFrameRecommendation[] {850 ): readonly SkellFrameRecommendation[] {
851+ const lang = request.lang ?? "en";
794 const pilotLevel = clampLevel(request.pilotLevel);852 const pilotLevel = clampLevel(request.pilotLevel);
795 if (!request.hasLicense || pilotLevel < 20) return [];853 if (!request.hasLicense || pilotLevel < 20) return [];
796 854
@@ -823,16 +881,23 @@ export function recommendSkellAlternatives(
823 .map((alt) => ({881 .map((alt) => ({
824 ...alt,882 ...alt,
825 reason: alt.prerequisite883 reason: alt.prerequisite
826- ? `Quest-locked frame at the same tier (${alt.prerequisite})`884+ ? lang === "fr"
885+ ? `Châssis verrouillé par une quête au même palier (${alt.prerequisite})`
886+ : `Quest-locked frame at the same tier (${alt.prerequisite})`
827 : postgame887 : postgame
828- ? "Postgame craftable frame at the same tier"888+ ? lang === "fr"
829- : "Alternative shop frame at the same tier",889+ ? "Châssis fabricable postgame au même palier"
890+ : "Postgame craftable frame at the same tier"
891+ : lang === "fr"
892+ ? "Châssis de boutique alternatif au même palier"
893+ : "Alternative shop frame at the same tier",
830 source: skellSource,894 source: skellSource,
831 }))895 }))
832 .sort((left, right) =>896 .sort((left, right) =>
833 effectiveness(right.frame) - effectiveness(left.frame)897 effectiveness(right.frame) - effectiveness(left.frame)
834 || compareText(left.frame, right.frame),898 || compareText(left.frame, right.frame),
835- );899+ )
900+ .slice(0, 5);
836 }901 }
837 902
838 /**903 /**