feat: localize Skell recommendations and refine build results
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 | ||
| 66 | 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 | 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 | 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 | 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 | 71 | .lvl-badge:hover,.lvl-badge:focus-visible{border-color:var(--cyan);background:rgba(150,239,240,.08)} |
| 72 | 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} |
| @@ -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 @@ | ||
| 1 | 1 | import { armsManufacturers } from "@/src/data/arms"; |
| 2 | 2 | import { buildStrategies } from "@/src/data/builds"; |
| 3 | +import { localizedGameUiText } from "@/src/data/i18n"; | |
| 3 | 4 | import type { Copy } from "@/src/data/i18n"; |
| 4 | 5 | import { |
| 5 | 6 | localized, |
| @@ -33,6 +34,7 @@ export default function BuildCard({ | ||
| 33 | 34 | pilotLevel: member.effectiveLevel, |
| 34 | 35 | hasLicense: hasSkellLicense, |
| 35 | 36 | postgameFrames, |
| 37 | + lang, | |
| 36 | 38 | }); |
| 37 | 39 | const loadout = skell ? recommendSkellLoadout(member.strategy, skell.requiredLevel) : null; |
| 38 | 40 | const alternatives = skell |
| @@ -41,6 +43,7 @@ export default function BuildCard({ | ||
| 41 | 43 | pilotLevel: member.effectiveLevel, |
| 42 | 44 | hasLicense: hasSkellLicense, |
| 43 | 45 | postgameFrames, |
| 46 | + lang, | |
| 44 | 47 | }) |
| 45 | 48 | : []; |
| 46 | 49 | |
| @@ -48,7 +51,7 @@ export default function BuildCard({ | ||
| 48 | 51 | <article className="build-card"> |
| 49 | 52 | <header className="build-card-head"> |
| 50 | 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 | 55 | <div className="role-tag"><small>{strategy.damage}</small><b>{localized(strategy.role, lang)}</b></div> |
| 53 | 56 | </header> |
| 54 | 57 | <div className="build-columns"> |
| @@ -62,7 +65,7 @@ export default function BuildCard({ | ||
| 62 | 65 | <b title={art.reason}>{art.name}</b> |
| 63 | 66 | <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small> |
| 64 | 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 | 69 | </a> |
| 67 | 70 | </li> |
| 68 | 71 | ))} |
| @@ -76,7 +79,7 @@ export default function BuildCard({ | ||
| 76 | 79 | <span key={`${skill.source.id}-${skill.name}`}> |
| 77 | 80 | <b title={skill.reason}>{skill.name}</b> |
| 78 | 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 | 83 | </a> |
| 81 | 84 | </span> |
| 82 | 85 | ))} |
| @@ -84,7 +87,7 @@ export default function BuildCard({ | ||
| 84 | 87 | |
| 85 | 88 | <h5>{t.heuristicStatPriority}</h5> |
| 86 | 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 | 91 | </ol> |
| 89 | 92 | |
| 90 | 93 | <h5>{t.amRecommended}</h5> |
| @@ -100,19 +103,19 @@ export default function BuildCard({ | ||
| 100 | 103 | <h4><span>K</span>{t.skellSection}</h4> |
| 101 | 104 | {skell && loadout ? <> |
| 102 | 105 | <div className="skell-frame"> |
| 103 | - <small>FRAME</small> | |
| 106 | + <small>{t.skellFrame}</small> | |
| 104 | 107 | <strong>{skell.frame}</strong> |
| 105 | 108 | <span>{localized(strategy.skellRole, lang)}</span> |
| 106 | 109 | </div> |
| 107 | 110 | <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> | |
| 110 | 113 | <div><dt>{t.reason}</dt><dd>{skell.reason}</dd></div> |
| 111 | 114 | </dl> |
| 112 | 115 | {alternatives.length > 0 && <div className="skell-alts"> |
| 113 | 116 | <small>{t.skellAlternatives}</small> |
| 114 | 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 | 120 | </div>} |
| 118 | 121 | <h5>{t.skellWeapons}</h5> |
| @@ -120,7 +123,8 @@ export default function BuildCard({ | ||
| 120 | 123 | {loadout.weapons.map((weapon) => ( |
| 121 | 124 | <li key={weapon.name}> |
| 122 | 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 | 128 | </li> |
| 125 | 129 | ))} |
| 126 | 130 | </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 = skell | 40 | 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() { | ||
| 53 | 53 | classRanks, |
| 54 | 54 | resolvedTeam, |
| 55 | 55 | personalRanks, |
| 56 | + lang, | |
| 56 | 57 | ); |
| 57 | 58 | |
| 58 | 59 | 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 { | ||
| 7 | 7 | title: string; |
| 8 | 8 | subtitle: string; |
| 9 | 9 | avatarLevel: string; |
| 10 | + levelShort: string; | |
| 10 | 11 | personalRank: string; |
| 11 | 12 | personalRankShort: string; |
| 12 | 13 | operator: string; |
| @@ -39,6 +40,7 @@ export interface Copy { | ||
| 39 | 40 | amRecommended: string; |
| 40 | 41 | planReasons: string; |
| 41 | 42 | skellSection: string; |
| 43 | + skellFrame: string; | |
| 42 | 44 | skellAlternatives: string; |
| 43 | 45 | skellWeapons: string; |
| 44 | 46 | skellAugments: string; |
| @@ -62,11 +64,12 @@ export const copy: Record<Lang, Copy> = { | ||
| 62 | 64 | eyebrow: "BLADE // TACTICAL SYSTEM 07", |
| 63 | 65 | title: "Team Architect", |
| 64 | 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 | 69 | personalRank: "Personal class rank", |
| 67 | 70 | personalRankShort: "RANK", |
| 68 | 71 | operator: "Companion slot", |
| 69 | - activeClass: "Avatar active class", | |
| 72 | + activeClass: "Cross's active class", | |
| 70 | 73 | rank: "Active class rank", |
| 71 | 74 | hierarchy: "Class route", |
| 72 | 75 | weapons: "Native weapons", |
| @@ -80,7 +83,7 @@ export const copy: Record<Lang, Copy> = { | ||
| 80 | 83 | skellLicense: "Has Skell License", |
| 81 | 84 | postgameFrames: "Postgame frame schematics", |
| 82 | 85 | viewResults: "View live recommendation", |
| 83 | - resultsHeading: "Progression-safe general-purpose recommendation", | |
| 86 | + resultsHeading: "All-rounder recommendation", | |
| 84 | 87 | activeRoute: "ACTIVE ROUTE", |
| 85 | 88 | teamMode: "TEAM MODE", |
| 86 | 89 | manualPick: "MANUAL", |
| @@ -95,6 +98,7 @@ export const copy: Record<Lang, Copy> = { | ||
| 95 | 98 | amRecommended: "RECOMMENDED AM", |
| 96 | 99 | planReasons: "PLAN REASONS", |
| 97 | 100 | skellSection: "SKELL", |
| 101 | + skellFrame: "FRAME", | |
| 98 | 102 | skellAlternatives: "Alternatives", |
| 99 | 103 | skellWeapons: "Weapons", |
| 100 | 104 | skellAugments: "Augments", |
| @@ -116,11 +120,12 @@ export const copy: Record<Lang, Copy> = { | ||
| 116 | 120 | eyebrow: "BLADE // SYSTÈME TACTIQUE 07", |
| 117 | 121 | title: "Architecte d’équipe", |
| 118 | 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 | 125 | personalRank: "Rang de classe personnel", |
| 121 | 126 | personalRankShort: "RANG", |
| 122 | 127 | operator: "Emplacement de compagnon", |
| 123 | - activeClass: "Classe active de l’Avatar", | |
| 128 | + activeClass: "Classe active de Cross", | |
| 124 | 129 | rank: "Rang de la classe active", |
| 125 | 130 | hierarchy: "Filière de classe", |
| 126 | 131 | weapons: "Armes natives", |
| @@ -132,9 +137,9 @@ export const copy: Record<Lang, Copy> = { | ||
| 132 | 137 | manualState: "MANUEL", |
| 133 | 138 | skellProgression: "Progression Skell", |
| 134 | 139 | skellLicense: "Licence Skell obtenue", |
| 135 | - postgameFrames: "Schémas de frames postgame", | |
| 140 | + postgameFrames: "Schémas de châssis postgame", | |
| 136 | 141 | viewResults: "Voir la recommandation en direct", |
| 137 | - resultsHeading: "Recommandation généraliste sûre pour la progression", | |
| 142 | + resultsHeading: "Recommandation polyvalente", | |
| 138 | 143 | activeRoute: "FILIÈRE ACTIVE", |
| 139 | 144 | teamMode: "MODE D’ÉQUIPE", |
| 140 | 145 | manualPick: "MANUEL", |
| @@ -149,15 +154,16 @@ export const copy: Record<Lang, Copy> = { | ||
| 149 | 154 | amRecommended: "AM RECOMMANDÉ", |
| 150 | 155 | planReasons: "RAISONS DU PLAN", |
| 151 | 156 | skellSection: "SKELL", |
| 157 | + skellFrame: "CHÂSSIS", | |
| 152 | 158 | skellAlternatives: "Alternatives", |
| 153 | 159 | skellWeapons: "Armes", |
| 154 | 160 | skellAugments: "Augments", |
| 155 | 161 | requiredPilotLevel: "Niveau de pilote requis", |
| 156 | 162 | acquisition: "Acquisition", |
| 157 | 163 | 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.", | |
| 161 | 167 | methodTitle: "Portée et méthode", |
| 162 | 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 | 169 | footerTagline: "Compagnon tactique non officiel", |
| @@ -166,3 +172,48 @@ export const copy: Record<Lang, Copy> = { | ||
| 166 | 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 | +} | |
| @@ -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", () => | ||
| 151 | 151 | const resolved = resolveTeam(["Lin", "Elma", "Irina"], "duelist", 100); |
| 152 | 152 | assert.deepEqual(resolved.slots.map((slot) => slot.effectiveLevel), [99, 99, 99]); |
| 153 | 153 | const roster = buildRoster(Number.NaN, "duelist", duelistRoute, resolved, [3, 3, 3]); |
| 154 | + assert.equal(roster[0]?.name, "Cross"); | |
| 154 | 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 | 186 | assert.deepEqual( |
| 175 | 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 | 199 | "AS0430 Lailah ST", |
| 188 | 200 | "XS0630 Mastema ST", |
| 189 | 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 | 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 | 208 | "XS0650 Mastema ST", |
| 201 | 209 | "AS0450 Lailah ST", |
| 202 | 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 | 213 | const postgame = recommendSkellAlternatives({ |
| @@ -215,11 +219,6 @@ test("Skell alternatives rank every same-tier frame by strategy effectiveness", | ||
| 215 | 219 | "Ares 70", |
| 216 | 220 | "Lailah Queen", |
| 217 | 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 | |
| @@ -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 { | ||
| 77 | 77 | pilotLevel: number; |
| 78 | 78 | hasLicense: boolean; |
| 79 | 79 | postgameFrames?: boolean; |
| 80 | + lang?: Lang; | |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | 83 | function compareText(left: string, right: string): number { |
| @@ -115,6 +116,10 @@ export function localized(pair: LocalizedText, lang: Lang): string { | ||
| 115 | 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 | 123 | /** Walks the class tree from Drifter down to the selected class. */ |
| 119 | 124 | export function classRoute(selected: ClassEntry): ClassEntry[] { |
| 120 | 125 | const chain: ClassEntry[] = []; |
| @@ -303,6 +308,7 @@ function selectAvatarArts( | ||
| 303 | 308 | learnedArts: readonly LearnedArt[], |
| 304 | 309 | weapons: WeaponPair, |
| 305 | 310 | preferences: readonly string[], |
| 311 | + lang: Lang, | |
| 306 | 312 | ): ArtSelection[] { |
| 307 | 313 | return learnedArts |
| 308 | 314 | .filter((art) => art.weapon === weapons.ranged || art.weapon === weapons.melee) |
| @@ -325,8 +331,16 @@ function selectAvatarArts( | ||
| 325 | 331 | citation: art.source, |
| 326 | 332 | }, |
| 327 | 333 | 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 | + ), | |
| 330 | 344 | }; |
| 331 | 345 | }); |
| 332 | 346 | } |
| @@ -335,6 +349,7 @@ function selectAvatarSkills( | ||
| 335 | 349 | learnedSkills: readonly LearnedSkill[], |
| 336 | 350 | preferences: readonly string[], |
| 337 | 351 | skillSlots: number, |
| 352 | + lang: Lang, | |
| 338 | 353 | ): SkillSelection[] { |
| 339 | 354 | return [...learnedSkills] |
| 340 | 355 | .sort((left, right) => |
| @@ -355,8 +370,16 @@ function selectAvatarSkills( | ||
| 355 | 370 | citation: skill.source, |
| 356 | 371 | }, |
| 357 | 372 | 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 | + ), | |
| 360 | 383 | }; |
| 361 | 384 | }); |
| 362 | 385 | } |
| @@ -369,6 +392,7 @@ export function planAvatar( | ||
| 369 | 392 | activeClassId: string, |
| 370 | 393 | classRanks: ClassRanks, |
| 371 | 394 | strategy: StrategyKey = classStrategy[activeClassId], |
| 395 | + lang: Lang = "en", | |
| 372 | 396 | ): AvatarPlan { |
| 373 | 397 | const activeClass = classById.get(activeClassId); |
| 374 | 398 | const progression = classProgressionById.get(activeClassId); |
| @@ -391,11 +415,12 @@ export function planAvatar( | ||
| 391 | 415 | || compareText(left.pair.ranged, right.pair.ranged) |
| 392 | 416 | || compareText(left.pair.melee, right.pair.melee)); |
| 393 | 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 | 419 | const skills = selectAvatarSkills( |
| 396 | 420 | learned.skills, |
| 397 | 421 | strategyData.preferredSkills, |
| 398 | 422 | progression.skillSlots, |
| 423 | + lang, | |
| 399 | 424 | ); |
| 400 | 425 | const masteredTierFour = tierFourClasses |
| 401 | 426 | .filter((entry) => normalizedClassRanks[entry.id] === 10) |
| @@ -403,7 +428,7 @@ export function planAvatar( | ||
| 403 | 428 | |
| 404 | 429 | return { |
| 405 | 430 | activeClassId, |
| 406 | - activeClassName: activeClass.en, | |
| 431 | + activeClassName: activeClass[lang], | |
| 407 | 432 | activeRank: normalizedClassRanks[activeClassId] ?? 0, |
| 408 | 433 | normalizedClassRanks, |
| 409 | 434 | strategy, |
| @@ -413,11 +438,23 @@ export function planAvatar( | ||
| 413 | 438 | skills, |
| 414 | 439 | skillSlots: progression.skillSlots, |
| 415 | 440 | 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 | + ), | |
| 418 | 451 | 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"), | |
| 421 | 458 | ], |
| 422 | 459 | }; |
| 423 | 460 | } |
| @@ -425,6 +462,7 @@ export function planAvatar( | ||
| 425 | 462 | function selectCompanionArts( |
| 426 | 463 | companion: (typeof companions)[number], |
| 427 | 464 | personalRank: number, |
| 465 | + lang: Lang, | |
| 428 | 466 | ): ArtSelection[] { |
| 429 | 467 | const strategy = buildStrategies[companion.strategy]; |
| 430 | 468 | return companion.arts |
| @@ -451,10 +489,10 @@ function selectCompanionArts( | ||
| 451 | 489 | citation: companion.source, |
| 452 | 490 | }, |
| 453 | 491 | 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 | 493 | : 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}`), | |
| 458 | 496 | }; |
| 459 | 497 | }); |
| 460 | 498 | } |
| @@ -462,6 +500,7 @@ function selectCompanionArts( | ||
| 462 | 500 | function selectCompanionSkills( |
| 463 | 501 | companion: (typeof companions)[number], |
| 464 | 502 | personalRank: number, |
| 503 | + lang: Lang, | |
| 465 | 504 | ): SkillSelection[] { |
| 466 | 505 | const preferences = buildStrategies[companion.strategy].preferredSkills; |
| 467 | 506 | return companion.skills |
| @@ -484,8 +523,8 @@ function selectCompanionSkills( | ||
| 484 | 523 | citation: companion.source, |
| 485 | 524 | }, |
| 486 | 525 | 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}`), | |
| 489 | 528 | }; |
| 490 | 529 | }); |
| 491 | 530 | } |
| @@ -495,6 +534,7 @@ export function planCompanion( | ||
| 495 | 534 | name: string, |
| 496 | 535 | effectiveLevel: number, |
| 497 | 536 | personalClassRank: number, |
| 537 | + lang: Lang = "en", | |
| 498 | 538 | ): CompanionPlan { |
| 499 | 539 | const companion = companionByName.get(name); |
| 500 | 540 | if (!companion) throw new Error(`Unknown companion: ${name}`); |
| @@ -503,8 +543,8 @@ export function planCompanion( | ||
| 503 | 543 | companion.startRank, |
| 504 | 544 | clampRank(personalClassRank, companion.maxRank), |
| 505 | 545 | ); |
| 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); | |
| 508 | 548 | return { |
| 509 | 549 | name: companion.name, |
| 510 | 550 | className: companion.className, |
| @@ -519,8 +559,16 @@ export function planCompanion( | ||
| 519 | 559 | note: buildStrategies[companion.strategy].role, |
| 520 | 560 | source: companion.source, |
| 521 | 561 | 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 | + ), | |
| 524 | 572 | ], |
| 525 | 573 | }; |
| 526 | 574 | } |
| @@ -703,12 +751,13 @@ export function buildRoster( | ||
| 703 | 751 | classRanks: ClassRanks, |
| 704 | 752 | team: TeamResolution, |
| 705 | 753 | personalClassRanks: readonly [number, number, number], |
| 754 | + lang: Lang = "en", | |
| 706 | 755 | ): RosterMember[] { |
| 707 | - const avatar = planAvatar(activeClassId, classRanks); | |
| 756 | + const avatar = planAvatar(activeClassId, classRanks, undefined, lang); | |
| 708 | 757 | const strategyNote = buildStrategies[avatar.strategy].role; |
| 709 | 758 | const roster: RosterMember[] = [{ |
| 710 | 759 | kind: "avatar", |
| 711 | - name: "Avatar", | |
| 760 | + name: "Cross", | |
| 712 | 761 | className: avatar.activeClassName, |
| 713 | 762 | effectiveLevel: clampLevel(avatarLevel), |
| 714 | 763 | rank: avatar.activeRank, |
| @@ -728,6 +777,7 @@ export function buildRoster( | ||
| 728 | 777 | slot.name, |
| 729 | 778 | slot.effectiveLevel, |
| 730 | 779 | personalClassRanks[index], |
| 780 | + lang, | |
| 731 | 781 | ); |
| 732 | 782 | roster.push({ |
| 733 | 783 | kind: "companion", |
| @@ -753,23 +803,28 @@ export function buildRoster( | ||
| 753 | 803 | export function recommendSkellFrame( |
| 754 | 804 | request: SkellRecommendationRequest, |
| 755 | 805 | ): SkellFrameRecommendation | null { |
| 806 | + const lang = request.lang ?? "en"; | |
| 756 | 807 | const pilotLevel = clampLevel(request.pilotLevel); |
| 757 | 808 | if (!request.hasLicense || pilotLevel < 20) return null; |
| 758 | 809 | |
| 759 | 810 | if (pilotLevel < 30) { |
| 760 | 811 | return { |
| 761 | 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 | 816 | source: skellSource, |
| 764 | 817 | }; |
| 765 | 818 | } |
| 766 | 819 | |
| 767 | 820 | const family = strategyFrameFamily[request.strategy]; |
| 768 | - const strategyRole = buildStrategies[request.strategy].role[0]; | |
| 821 | + const strategyRole = localized(buildStrategies[request.strategy].role, lang); | |
| 769 | 822 | if (pilotLevel >= 60 && request.postgameFrames === true) { |
| 770 | 823 | return { |
| 771 | 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 | 828 | source: skellSource, |
| 774 | 829 | }; |
| 775 | 830 | } |
| @@ -777,7 +832,9 @@ export function recommendSkellFrame( | ||
| 777 | 832 | const tier: 30 | 50 = pilotLevel >= 50 ? 50 : 30; |
| 778 | 833 | return { |
| 779 | 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 | 838 | source: skellSource, |
| 782 | 839 | }; |
| 783 | 840 | } |
| @@ -791,6 +848,7 @@ export function recommendSkellFrame( | ||
| 791 | 848 | export function recommendSkellAlternatives( |
| 792 | 849 | request: SkellRecommendationRequest, |
| 793 | 850 | ): readonly SkellFrameRecommendation[] { |
| 851 | + const lang = request.lang ?? "en"; | |
| 794 | 852 | const pilotLevel = clampLevel(request.pilotLevel); |
| 795 | 853 | if (!request.hasLicense || pilotLevel < 20) return []; |
| 796 | 854 | |
| @@ -823,16 +881,23 @@ export function recommendSkellAlternatives( | ||
| 823 | 881 | .map((alt) => ({ |
| 824 | 882 | ...alt, |
| 825 | 883 | 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 | 887 | : 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", | |
| 830 | 894 | source: skellSource, |
| 831 | 895 | })) |
| 832 | 896 | .sort((left, right) => |
| 833 | 897 | effectiveness(right.frame) - effectiveness(left.frame) |
| 834 | 898 | || compareText(left.frame, right.frame), |
| 835 | - ); | |
| 899 | + ) | |
| 900 | + .slice(0, 5); | |
| 836 | 901 | } |
| 837 | 902 | |
| 838 | 903 | /** |
| @@ -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 learnedArts | 313 | 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.length | 333 | 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.length | 372 | 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 = tierFourClasses | 425 | 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 > 0 | 451 | 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.arts | 468 | return companion.arts |
| @@ -451,10 +489,10 @@ function selectCompanionArts( | |||
| 451 | citation: companion.source, | 489 | citation: companion.source, |
| 452 | }, | 490 | }, |
| 453 | reason: defaultAt < companion.defaultArts.length | 491 | 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.length | 493 | : 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.skills | 506 | return companion.skills |
| @@ -484,8 +523,8 @@ function selectCompanionSkills( | |||
| 484 | citation: companion.source, | 523 | citation: companion.source, |
| 485 | }, | 524 | }, |
| 486 | reason: preferredAt < preferences.length | 525 | 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.prerequisite | 883 | 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 | : postgame | 887 | : 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 | /** |