anthodev/xeno-team-builderpublic Fork
⑂ 2865d59
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: add progression-aware contextual team and weapon recommendations

Cedric ANTHONY committed 2026-08-11T05:56:09+02:00 Browse files
2865d59 parent: 25361d4
modified app/globals.css +8 -1
@@ -43,6 +43,7 @@ select:focus { border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.0
4343 .operator-index { text-align:center;color:#557d80;font-size:14px}.operator>select{border:0;border-left:1px solid rgba(150,239,240,.09);background-color:transparent;padding-top:14px;padding-bottom:14px}.operator-state{font-size:11px;letter-spacing:.12em;color:#658085;padding:0 9px}.operator-state.online{color:var(--mint)}.operator-metrics{display:flex;align-items:stretch;border-left:1px solid rgba(150,239,240,.09)}.operator-metrics label{display:grid;grid-template-columns:auto 52px;align-items:center;gap:5px;padding:0 7px;color:#638689;font-size:11px;letter-spacing:.08em}.operator-metrics label+label{border-left:1px solid rgba(150,239,240,.09)}.operator-metrics input{width:52px;background:#07171c;border:1px solid rgba(150,239,240,.18);color:var(--cyan);padding:7px 3px;text-align:center;outline:none}.operator-metrics input:focus{border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.08)}.progression-controls{display:grid;grid-template-columns:1fr 1fr;gap:9px 14px;margin:18px 0 0;padding:13px 14px;border:1px solid var(--line)}.progression-controls legend{padding:0 6px;color:#6f9193;font-size:12px;letter-spacing:.14em;text-transform:uppercase}.progression-controls label{display:flex;align-items:center;gap:8px;color:#9bb2b1;font-size:13px}.progression-controls label.disabled{opacity:.45}.progression-controls input{accent-color:var(--cyan)}
4444 .generate-button { width:100%;margin-top:19px;border:0;background:var(--cyan);color:#061318;display:flex;align-items:center;justify-content:space-between;padding:17px 18px;text-transform:uppercase;font-size:15px;letter-spacing:.13em;font-weight:900;cursor:pointer;clip-path:polygon(0 0,96% 0,100% 28%,100% 100%,0 100%);transition:.2s ease; }
4545 .generate-button:hover { background:white;transform:translateY(-2px);box-shadow:0 12px 30px rgba(150,239,240,.18)}.generate-button b{font-size:24px}
46+.weapon-controls{margin:20px 0 0;padding:14px;border:1px solid var(--line);background:rgba(4,15,18,.28)}.weapon-controls legend{padding:0 7px;color:#6f9193;font-size:12px;letter-spacing:.14em;text-transform:uppercase}.weapon-field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.weapon-field-row label{display:flex;flex-direction:column}.weapon-field-row label>span{font-size:11px;color:#638689;letter-spacing:.12em;text-transform:uppercase;margin-bottom:7px}.weapon-controls>p{margin:10px 0 0;color:#587b7e;font-size:11px;line-height:1.5}
4647 .ranks-toggle { width:100%;display:flex;align-items:center;justify-content:space-between;margin-top:18px;padding:12px 0;border:0;border-top:1px solid var(--line);background:none;color:#6e9698;text-transform:uppercase;letter-spacing:.13em;font-size:13px;cursor:pointer}.ranks-toggle b{font-size:23px;color:var(--cyan)}
4748 .rank-hint { margin:10px 0 6px; font-size:13px; color:#6f9193; letter-spacing:.1em; line-height:1.5; }
4849 .rank-tree { border-top:1px solid var(--line); margin-top:12px; padding-top:6px; max-height:340px; overflow:auto; }
@@ -70,9 +71,15 @@ select:focus { border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.0
7071 .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 }
7172 .lvl-badge:hover,.lvl-badge:focus-visible{border-color:var(--cyan);background:rgba(150,239,240,.08)}
7273 .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}
74+.contextual-controls .combat-mode-field{grid-column:1/-1;display:grid;grid-template-columns:auto minmax(150px,1fr);gap:12px}.contextual-controls .combat-mode-field select{padding-top:8px;padding-bottom:8px}.contextual-controls .special-unlock-field{grid-column:1/-1}.recommendation-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px}.recommendation-tabs button{display:grid;gap:5px;text-align:left;padding:14px 16px;border:1px solid var(--line);background:rgba(8,29,34,.58);color:#718f91;cursor:pointer}.recommendation-tabs button span{font-size:11px;letter-spacing:.15em;text-transform:uppercase}.recommendation-tabs button b{font-size:14px;color:#a8bfbd;font-weight:500}.recommendation-tabs button small{font-size:11px;color:#55787c}.recommendation-tabs button:hover{border-color:rgba(150,239,240,.45)}.recommendation-tabs button.active{border-color:var(--cyan);background:linear-gradient(120deg,rgba(150,239,240,.11),rgba(110,243,197,.04));box-shadow:inset 3px 0 var(--cyan)}.recommendation-tabs button.active span,.recommendation-tabs button.active small{color:var(--cyan)}.recommendation-tabs button.active b{color:#e5f3ef}.recommendation-context{display:grid;grid-template-columns:1.4fr auto 1fr;gap:18px;align-items:start;border:1px solid var(--line);border-top:0;background:rgba(4,18,22,.58);padding:15px 18px;margin:-12px 0 12px}.recommendation-context small,.infeasible-panel small{display:block;font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:#63888a;margin-bottom:7px}.recommendation-context ul,.infeasible-panel ul{margin:0;padding-left:18px;color:#91aaa8;font-size:13px;line-height:1.5}.score-grid{display:flex;gap:7px;margin:0}.score-grid div{min-width:52px;padding:7px;border:1px solid rgba(150,239,240,.12);text-align:center}.score-grid dt{font-size:9px;color:#55787c;letter-spacing:.1em}.score-grid dd{margin:3px 0 0;color:var(--mint);font-size:13px}.recommendation-evidence{display:flex;flex-wrap:wrap;gap:6px}.recommendation-evidence small{width:100%}.recommendation-evidence a,.skell-evidence a{font-size:11px;color:#80a9aa;text-decoration:none;border:1px solid rgba(150,239,240,.15);padding:4px 6px}.recommendation-evidence a:hover,.skell-evidence a:hover{color:var(--cyan);border-color:var(--cyan)}.infeasible-panel{display:grid;grid-template-columns:1fr 1fr;gap:20px;border:1px solid rgba(255,113,79,.3);background:rgba(255,113,79,.05);padding:22px}.arts-block li{grid-template-areas:"num name badge" "num weapon badge" "num desc badge" "num fallback badge"}.arts-block li.special-art{border-color:rgba(255,113,79,.3);background:rgba(255,113,79,.045)}.special-art-badge{display:inline-block;margin-left:7px;padding:2px 4px;border:1px solid rgba(255,113,79,.35);color:#ff9479;font-size:8px;font-style:normal;letter-spacing:.08em;text-transform:uppercase}.arts-block li .art-fallback{grid-area:fallback;display:grid;gap:2px;margin:5px 0 0;padding:5px 7px;border-left:2px solid var(--mint);background:rgba(110,243,197,.04)}.arts-block li .art-fallback strong{font-size:10px;color:#98c8b8}.arts-block li .art-fallback span{font-size:10px;color:#5f8180}.skell-evidence{display:flex;flex-wrap:wrap;gap:5px;margin-top:12px}
75+.recommendation-context{grid-template-columns:1.4fr auto}
76+.skell-package-notes{margin:10px 0 14px;padding-left:18px;color:#789698;font-size:11px;line-height:1.5;display:grid;gap:4px}
77+.skill-unlock{font-size:9px;color:#5d8487;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap}
7378 footer{max-width:1760px;margin:0 auto;padding:28px 5vw 40px;border-top:1px solid var(--line);position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between}footer b,footer span{display:block}footer b{font-size:14px;letter-spacing:.18em;color:#91adaa}footer span{font-size:12px;color:#537477;margin-top:5px}footer nav{display:flex;gap:17px}footer a{font-size:13px;color:#6b9596;text-decoration:none;border-bottom:1px solid transparent;padding-bottom:3px}footer a:hover{color:var(--cyan);border-color:var(--cyan)}
79+.footer-legal{max-width:960px;margin:10px 0 0;color:#496b6e;font-size:11px;line-height:1.55}
7480 @media(max-width:1300px){.build-columns{grid-template-columns:1fr 1fr}.loadout-block:nth-child(2){border-right:0}.build-columns .skell-block{grid-column:1/-1;border-top:1px solid var(--line);border-right:0}}
7581 @media(max-width:1100px){.operator{grid-template-columns:34px minmax(0,1fr) auto}.operator-metrics{grid-column:2/4;border-top:1px solid rgba(150,239,240,.09)}.operator-metrics label{flex:1}}
76-@media(max-width:850px){.builder-grid{grid-template-columns:1fr}.top-status{display:none}.section-code{display:none}.squad-summary{grid-template-columns:1fr}.squad-summary>div{border-right:0;border-bottom:1px solid var(--line)}footer{align-items:flex-start;gap:20px}footer nav{flex-wrap:wrap;justify-content:flex-end}}
82+@media(max-width:850px){.builder-grid{grid-template-columns:1fr}.top-status{display:none}.section-code{display:none}.squad-summary{grid-template-columns:1fr}.squad-summary>div{border-right:0;border-bottom:1px solid var(--line)}.recommendation-context{grid-template-columns:1fr}.recommendation-tabs{grid-template-columns:1fr}footer{align-items:flex-start;gap:20px}footer nav{flex-wrap:wrap;justify-content:flex-end}}
7783 @media(max-width:560px){.topbar{padding:0 20px}.builder-shell,.results{padding-left:20px;padding-right:20px}.brand small{display:none}.panel{padding:20px}.field-row,.class-readout{grid-template-columns:1fr}.operator{grid-template-columns:28px minmax(0,1fr);padding:7px 0}.operator>select{border-bottom:1px solid rgba(150,239,240,.09)}.operator-state{display:none}.operator-metrics{grid-column:2;display:grid;grid-template-columns:1fr 1fr;border-left:1px solid rgba(150,239,240,.09)}.operator-metrics label{grid-template-columns:1fr 52px;padding:7px}.progression-controls{grid-template-columns:1fr}.build-columns{grid-template-columns:1fr}.skell-block{grid-column:auto}.loadout-block{border-right:0;border-bottom:1px solid var(--line)!important}.build-card-head{grid-template-columns:40px 1fr}.role-tag{display:none}.arts-block ol{grid-template-columns:1fr}.result-meta{display:none}.stat-priority-list{grid-template-columns:1fr}footer{flex-direction:column}footer nav{justify-content:flex-start}}
84+@media(max-width:560px){.weapon-field-row{grid-template-columns:1fr}}
7885 @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
@@ -43,6 +43,7 @@ select:focus { border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.0
43 .operator-index { text-align:center;color:#557d80;font-size:14px}.operator>select{border:0;border-left:1px solid rgba(150,239,240,.09);background-color:transparent;padding-top:14px;padding-bottom:14px}.operator-state{font-size:11px;letter-spacing:.12em;color:#658085;padding:0 9px}.operator-state.online{color:var(--mint)}.operator-metrics{display:flex;align-items:stretch;border-left:1px solid rgba(150,239,240,.09)}.operator-metrics label{display:grid;grid-template-columns:auto 52px;align-items:center;gap:5px;padding:0 7px;color:#638689;font-size:11px;letter-spacing:.08em}.operator-metrics label+label{border-left:1px solid rgba(150,239,240,.09)}.operator-metrics input{width:52px;background:#07171c;border:1px solid rgba(150,239,240,.18);color:var(--cyan);padding:7px 3px;text-align:center;outline:none}.operator-metrics input:focus{border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.08)}.progression-controls{display:grid;grid-template-columns:1fr 1fr;gap:9px 14px;margin:18px 0 0;padding:13px 14px;border:1px solid var(--line)}.progression-controls legend{padding:0 6px;color:#6f9193;font-size:12px;letter-spacing:.14em;text-transform:uppercase}.progression-controls label{display:flex;align-items:center;gap:8px;color:#9bb2b1;font-size:13px}.progression-controls label.disabled{opacity:.45}.progression-controls input{accent-color:var(--cyan)}43 .operator-index { text-align:center;color:#557d80;font-size:14px}.operator>select{border:0;border-left:1px solid rgba(150,239,240,.09);background-color:transparent;padding-top:14px;padding-bottom:14px}.operator-state{font-size:11px;letter-spacing:.12em;color:#658085;padding:0 9px}.operator-state.online{color:var(--mint)}.operator-metrics{display:flex;align-items:stretch;border-left:1px solid rgba(150,239,240,.09)}.operator-metrics label{display:grid;grid-template-columns:auto 52px;align-items:center;gap:5px;padding:0 7px;color:#638689;font-size:11px;letter-spacing:.08em}.operator-metrics label+label{border-left:1px solid rgba(150,239,240,.09)}.operator-metrics input{width:52px;background:#07171c;border:1px solid rgba(150,239,240,.18);color:var(--cyan);padding:7px 3px;text-align:center;outline:none}.operator-metrics input:focus{border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.08)}.progression-controls{display:grid;grid-template-columns:1fr 1fr;gap:9px 14px;margin:18px 0 0;padding:13px 14px;border:1px solid var(--line)}.progression-controls legend{padding:0 6px;color:#6f9193;font-size:12px;letter-spacing:.14em;text-transform:uppercase}.progression-controls label{display:flex;align-items:center;gap:8px;color:#9bb2b1;font-size:13px}.progression-controls label.disabled{opacity:.45}.progression-controls input{accent-color:var(--cyan)}
44 .generate-button { width:100%;margin-top:19px;border:0;background:var(--cyan);color:#061318;display:flex;align-items:center;justify-content:space-between;padding:17px 18px;text-transform:uppercase;font-size:15px;letter-spacing:.13em;font-weight:900;cursor:pointer;clip-path:polygon(0 0,96% 0,100% 28%,100% 100%,0 100%);transition:.2s ease; }44 .generate-button { width:100%;margin-top:19px;border:0;background:var(--cyan);color:#061318;display:flex;align-items:center;justify-content:space-between;padding:17px 18px;text-transform:uppercase;font-size:15px;letter-spacing:.13em;font-weight:900;cursor:pointer;clip-path:polygon(0 0,96% 0,100% 28%,100% 100%,0 100%);transition:.2s ease; }
45 .generate-button:hover { background:white;transform:translateY(-2px);box-shadow:0 12px 30px rgba(150,239,240,.18)}.generate-button b{font-size:24px}45 .generate-button:hover { background:white;transform:translateY(-2px);box-shadow:0 12px 30px rgba(150,239,240,.18)}.generate-button b{font-size:24px}
46+.weapon-controls{margin:20px 0 0;padding:14px;border:1px solid var(--line);background:rgba(4,15,18,.28)}.weapon-controls legend{padding:0 7px;color:#6f9193;font-size:12px;letter-spacing:.14em;text-transform:uppercase}.weapon-field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.weapon-field-row label{display:flex;flex-direction:column}.weapon-field-row label>span{font-size:11px;color:#638689;letter-spacing:.12em;text-transform:uppercase;margin-bottom:7px}.weapon-controls>p{margin:10px 0 0;color:#587b7e;font-size:11px;line-height:1.5}
46 .ranks-toggle { width:100%;display:flex;align-items:center;justify-content:space-between;margin-top:18px;padding:12px 0;border:0;border-top:1px solid var(--line);background:none;color:#6e9698;text-transform:uppercase;letter-spacing:.13em;font-size:13px;cursor:pointer}.ranks-toggle b{font-size:23px;color:var(--cyan)}47 .ranks-toggle { width:100%;display:flex;align-items:center;justify-content:space-between;margin-top:18px;padding:12px 0;border:0;border-top:1px solid var(--line);background:none;color:#6e9698;text-transform:uppercase;letter-spacing:.13em;font-size:13px;cursor:pointer}.ranks-toggle b{font-size:23px;color:var(--cyan)}
47 .rank-hint { margin:10px 0 6px; font-size:13px; color:#6f9193; letter-spacing:.1em; line-height:1.5; }48 .rank-hint { margin:10px 0 6px; font-size:13px; color:#6f9193; letter-spacing:.1em; line-height:1.5; }
48 .rank-tree { border-top:1px solid var(--line); margin-top:12px; padding-top:6px; max-height:340px; overflow:auto; }49 .rank-tree { border-top:1px solid var(--line); margin-top:12px; padding-top:6px; max-height:340px; overflow:auto; }
@@ -70,9 +71,15 @@ select:focus { border-color:var(--cyan);box-shadow:0 0 0 2px rgba(150,239,240,.0
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 { 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)}72 .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}73 .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}
74+.contextual-controls .combat-mode-field{grid-column:1/-1;display:grid;grid-template-columns:auto minmax(150px,1fr);gap:12px}.contextual-controls .combat-mode-field select{padding-top:8px;padding-bottom:8px}.contextual-controls .special-unlock-field{grid-column:1/-1}.recommendation-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px}.recommendation-tabs button{display:grid;gap:5px;text-align:left;padding:14px 16px;border:1px solid var(--line);background:rgba(8,29,34,.58);color:#718f91;cursor:pointer}.recommendation-tabs button span{font-size:11px;letter-spacing:.15em;text-transform:uppercase}.recommendation-tabs button b{font-size:14px;color:#a8bfbd;font-weight:500}.recommendation-tabs button small{font-size:11px;color:#55787c}.recommendation-tabs button:hover{border-color:rgba(150,239,240,.45)}.recommendation-tabs button.active{border-color:var(--cyan);background:linear-gradient(120deg,rgba(150,239,240,.11),rgba(110,243,197,.04));box-shadow:inset 3px 0 var(--cyan)}.recommendation-tabs button.active span,.recommendation-tabs button.active small{color:var(--cyan)}.recommendation-tabs button.active b{color:#e5f3ef}.recommendation-context{display:grid;grid-template-columns:1.4fr auto 1fr;gap:18px;align-items:start;border:1px solid var(--line);border-top:0;background:rgba(4,18,22,.58);padding:15px 18px;margin:-12px 0 12px}.recommendation-context small,.infeasible-panel small{display:block;font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:#63888a;margin-bottom:7px}.recommendation-context ul,.infeasible-panel ul{margin:0;padding-left:18px;color:#91aaa8;font-size:13px;line-height:1.5}.score-grid{display:flex;gap:7px;margin:0}.score-grid div{min-width:52px;padding:7px;border:1px solid rgba(150,239,240,.12);text-align:center}.score-grid dt{font-size:9px;color:#55787c;letter-spacing:.1em}.score-grid dd{margin:3px 0 0;color:var(--mint);font-size:13px}.recommendation-evidence{display:flex;flex-wrap:wrap;gap:6px}.recommendation-evidence small{width:100%}.recommendation-evidence a,.skell-evidence a{font-size:11px;color:#80a9aa;text-decoration:none;border:1px solid rgba(150,239,240,.15);padding:4px 6px}.recommendation-evidence a:hover,.skell-evidence a:hover{color:var(--cyan);border-color:var(--cyan)}.infeasible-panel{display:grid;grid-template-columns:1fr 1fr;gap:20px;border:1px solid rgba(255,113,79,.3);background:rgba(255,113,79,.05);padding:22px}.arts-block li{grid-template-areas:"num name badge" "num weapon badge" "num desc badge" "num fallback badge"}.arts-block li.special-art{border-color:rgba(255,113,79,.3);background:rgba(255,113,79,.045)}.special-art-badge{display:inline-block;margin-left:7px;padding:2px 4px;border:1px solid rgba(255,113,79,.35);color:#ff9479;font-size:8px;font-style:normal;letter-spacing:.08em;text-transform:uppercase}.arts-block li .art-fallback{grid-area:fallback;display:grid;gap:2px;margin:5px 0 0;padding:5px 7px;border-left:2px solid var(--mint);background:rgba(110,243,197,.04)}.arts-block li .art-fallback strong{font-size:10px;color:#98c8b8}.arts-block li .art-fallback span{font-size:10px;color:#5f8180}.skell-evidence{display:flex;flex-wrap:wrap;gap:5px;margin-top:12px}
75+.recommendation-context{grid-template-columns:1.4fr auto}
76+.skell-package-notes{margin:10px 0 14px;padding-left:18px;color:#789698;font-size:11px;line-height:1.5;display:grid;gap:4px}
77+.skill-unlock{font-size:9px;color:#5d8487;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap}
73 footer{max-width:1760px;margin:0 auto;padding:28px 5vw 40px;border-top:1px solid var(--line);position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between}footer b,footer span{display:block}footer b{font-size:14px;letter-spacing:.18em;color:#91adaa}footer span{font-size:12px;color:#537477;margin-top:5px}footer nav{display:flex;gap:17px}footer a{font-size:13px;color:#6b9596;text-decoration:none;border-bottom:1px solid transparent;padding-bottom:3px}footer a:hover{color:var(--cyan);border-color:var(--cyan)}78 footer{max-width:1760px;margin:0 auto;padding:28px 5vw 40px;border-top:1px solid var(--line);position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between}footer b,footer span{display:block}footer b{font-size:14px;letter-spacing:.18em;color:#91adaa}footer span{font-size:12px;color:#537477;margin-top:5px}footer nav{display:flex;gap:17px}footer a{font-size:13px;color:#6b9596;text-decoration:none;border-bottom:1px solid transparent;padding-bottom:3px}footer a:hover{color:var(--cyan);border-color:var(--cyan)}
79+.footer-legal{max-width:960px;margin:10px 0 0;color:#496b6e;font-size:11px;line-height:1.55}
74 @media(max-width:1300px){.build-columns{grid-template-columns:1fr 1fr}.loadout-block:nth-child(2){border-right:0}.build-columns .skell-block{grid-column:1/-1;border-top:1px solid var(--line);border-right:0}}80 @media(max-width:1300px){.build-columns{grid-template-columns:1fr 1fr}.loadout-block:nth-child(2){border-right:0}.build-columns .skell-block{grid-column:1/-1;border-top:1px solid var(--line);border-right:0}}
75 @media(max-width:1100px){.operator{grid-template-columns:34px minmax(0,1fr) auto}.operator-metrics{grid-column:2/4;border-top:1px solid rgba(150,239,240,.09)}.operator-metrics label{flex:1}}81 @media(max-width:1100px){.operator{grid-template-columns:34px minmax(0,1fr) auto}.operator-metrics{grid-column:2/4;border-top:1px solid rgba(150,239,240,.09)}.operator-metrics label{flex:1}}
76-@media(max-width:850px){.builder-grid{grid-template-columns:1fr}.top-status{display:none}.section-code{display:none}.squad-summary{grid-template-columns:1fr}.squad-summary>div{border-right:0;border-bottom:1px solid var(--line)}footer{align-items:flex-start;gap:20px}footer nav{flex-wrap:wrap;justify-content:flex-end}}82+@media(max-width:850px){.builder-grid{grid-template-columns:1fr}.top-status{display:none}.section-code{display:none}.squad-summary{grid-template-columns:1fr}.squad-summary>div{border-right:0;border-bottom:1px solid var(--line)}.recommendation-context{grid-template-columns:1fr}.recommendation-tabs{grid-template-columns:1fr}footer{align-items:flex-start;gap:20px}footer nav{flex-wrap:wrap;justify-content:flex-end}}
77 @media(max-width:560px){.topbar{padding:0 20px}.builder-shell,.results{padding-left:20px;padding-right:20px}.brand small{display:none}.panel{padding:20px}.field-row,.class-readout{grid-template-columns:1fr}.operator{grid-template-columns:28px minmax(0,1fr);padding:7px 0}.operator>select{border-bottom:1px solid rgba(150,239,240,.09)}.operator-state{display:none}.operator-metrics{grid-column:2;display:grid;grid-template-columns:1fr 1fr;border-left:1px solid rgba(150,239,240,.09)}.operator-metrics label{grid-template-columns:1fr 52px;padding:7px}.progression-controls{grid-template-columns:1fr}.build-columns{grid-template-columns:1fr}.skell-block{grid-column:auto}.loadout-block{border-right:0;border-bottom:1px solid var(--line)!important}.build-card-head{grid-template-columns:40px 1fr}.role-tag{display:none}.arts-block ol{grid-template-columns:1fr}.result-meta{display:none}.stat-priority-list{grid-template-columns:1fr}footer{flex-direction:column}footer nav{justify-content:flex-start}}83 @media(max-width:560px){.topbar{padding:0 20px}.builder-shell,.results{padding-left:20px;padding-right:20px}.brand small{display:none}.panel{padding:20px}.field-row,.class-readout{grid-template-columns:1fr}.operator{grid-template-columns:28px minmax(0,1fr);padding:7px 0}.operator>select{border-bottom:1px solid rgba(150,239,240,.09)}.operator-state{display:none}.operator-metrics{grid-column:2;display:grid;grid-template-columns:1fr 1fr;border-left:1px solid rgba(150,239,240,.09)}.operator-metrics label{grid-template-columns:1fr 52px;padding:7px}.progression-controls{grid-template-columns:1fr}.build-columns{grid-template-columns:1fr}.skell-block{grid-column:auto}.loadout-block{border-right:0;border-bottom:1px solid var(--line)!important}.build-card-head{grid-template-columns:40px 1fr}.role-tag{display:none}.arts-block ol{grid-template-columns:1fr}.result-meta{display:none}.stat-priority-list{grid-template-columns:1fr}footer{flex-direction:column}footer nav{justify-content:flex-start}}
84+@media(max-width:560px){.weapon-field-row{grid-template-columns:1fr}}
78 @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}85 @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
modified package.json +1 -1
@@ -11,7 +11,7 @@
1111 "dev": "next dev",
1212 "build": "next build",
1313 "lint": "eslint .",
14- "test": "tsx --test src/lib/engine.test.ts",
14+ "test": "tsx --test src/lib/*.test.ts",
1515 "typecheck": "tsc --noEmit"
1616 },
1717 "dependencies": {
@@ -11,7 +11,7 @@
11 "dev": "next dev",11 "dev": "next dev",
12 "build": "next build",12 "build": "next build",
13 "lint": "eslint .",13 "lint": "eslint .",
14- "test": "tsx --test src/lib/engine.test.ts",14+ "test": "tsx --test src/lib/*.test.ts",
15 "typecheck": "tsc --noEmit"15 "typecheck": "tsc --noEmit"
16 },16 },
17 "dependencies": {17 "dependencies": {
modified src/components/BuildCard.tsx +30 -14
@@ -3,19 +3,20 @@ import { artDescriptions } from "@/src/data/artDescriptions";
33 import { buildStrategies } from "@/src/data/builds";
44 import { localizedGameUiText } from "@/src/data/i18n";
55 import type { Copy } from "@/src/data/i18n";
6+import { recommendContextualSkellLoadout } from "@/src/lib/contextualEngine";
67 import {
78 localized,
89 recommendSkellAlternatives,
910 recommendSkellFrame,
10- recommendSkellLoadout,
1111 } from "@/src/lib/engine";
12-import type { Lang, RosterMember } from "@/src/lib/types";
12+import type { CombatMode, Lang, RosterMember } from "@/src/lib/types";
1313
1414 interface BuildCardProps {
1515 index: number;
1616 member: RosterMember;
1717 lang: Lang;
1818 t: Copy;
19+ combatMode: CombatMode;
1920 hasSkellLicense: boolean;
2021 postgameFrames: boolean;
2122 }
@@ -25,6 +26,7 @@ export default function BuildCard({
2526 member,
2627 lang,
2728 t,
29+ combatMode,
2830 hasSkellLicense,
2931 postgameFrames,
3032 }: BuildCardProps) {
@@ -37,7 +39,15 @@ export default function BuildCard({
3739 postgameFrames,
3840 lang,
3941 });
40- const loadout = skell ? recommendSkellLoadout(member.strategy, skell.requiredLevel) : null;
42+ const loadout = skell
43+ ? recommendContextualSkellLoadout({
44+ strategy: member.strategy,
45+ frame: skell.frame,
46+ frameLevel: skell.requiredLevel,
47+ combatMode,
48+ lang,
49+ })
50+ : null;
4151 const alternatives = skell
4252 ? recommendSkellAlternatives({
4353 strategy: member.strategy,
@@ -61,14 +71,15 @@ export default function BuildCard({
6171 <p className="weapon-lock">{member.weapons.ranged} + {member.weapons.melee} · {localized(member.note, lang)}</p>
6272 {member.arts.length > 0 ? <ol>
6373 {member.arts.slice(0, 8).map((art, artIndex) => (
64- <li key={`${art.source.id}-${art.name}`}>
74+ <li className={art.specialUnlock ? "special-art" : undefined} key={`${art.name}-${artIndex}`}>
6575 <span>{String(artIndex + 1).padStart(2, "0")}</span>
66- <b title={art.reason}>{art.name}</b>
76+ <b title={art.reason}>{art.name}{art.specialUnlock && <em className="special-art-badge">{t.specialArt}</em>}</b>
6777 <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small>
6878 {artDescriptions[art.name] && <p className="art-desc">{localized(artDescriptions[art.name], lang)}</p>}
69- <a className="lvl-badge" href={art.source.url} target="_blank" rel="noreferrer" title={`${t.recommendedUpgrade} — ${art.source.citation}`}>
79+ {art.fallback && <p className="art-fallback"><strong>{t.fallback}: {art.fallback.name}</strong><span>{art.fallback.reason}</span></p>}
80+ <span className="lvl-badge" title={t.recommendedUpgrade}>
7081 {t.levelShort} {art.recommendedLevel}
71- </a>
82+ </span>
7283 </li>
7384 ))}
7485 </ol> : <p className="empty-loadout">{t.noLearnedArts}</p>}
@@ -78,12 +89,12 @@ export default function BuildCard({
7889 <h4><span>S</span>{t.skills} · {member.skills.length}/{member.skillSlots}</h4>
7990 {member.skills.length > 0 ? <div className="chip-list">
8091 {member.skills.map((skill) => (
81- <span key={`${skill.source.id}-${skill.name}`}>
92+ <span key={skill.name}>
8293 <b title={skill.reason}>{skill.name}</b>
83- <small className="lv-rec" title={t.recommendedUpgrade}>{t.levelShort} {skill.recommendedLevel}</small>
84- <a className="lvl-badge" href={skill.source.url} target="_blank" rel="noreferrer" title={skill.source.citation}>
85- {skill.source.id} · {t.personalRankShort} {skill.unlockRank}
86- </a>
94+ <small className="skill-unlock">{lang === "fr" ? "DÉBLOCAGE AU RANG" : "UNLOCK AT RANK"} {skill.unlockRank}</small>
95+ <span className="lvl-badge" title={t.recommendedUpgrade}>
96+ {t.levelShort} {Math.max(1, Math.min(5, skill.recommendedLevel))}
97+ </span>
8798 </span>
8899 ))}
89100 </div> : <p className="empty-loadout">{t.noLearnedSkills}</p>}
@@ -106,15 +117,20 @@ export default function BuildCard({
106117 <h4><span>K</span>{t.skellSection}</h4>
107118 {skell && loadout ? <>
108119 <div className="skell-frame">
109- <small>{t.skellFrame}</small>
120+ <small>{t.skellFrame} · {loadout.archetype}</small>
110121 <strong>{skell.frame}</strong>
111122 <span>{localized(strategy.skellRole, lang)}</span>
112123 </div>
113124 <dl className="skell-facts">
114125 <div><dt>{t.requiredPilotLevel}</dt><dd>{t.levelShort} {skell.requiredLevel}</dd></div>
115126 <div><dt>{t.acquisition}</dt><dd>{localizedGameUiText(skell.acquisition, lang)}</dd></div>
116- <div><dt>{t.reason}</dt><dd>{skell.reason}</dd></div>
127+ <div><dt>{t.reason}</dt><dd>{loadout.reason}</dd></div>
128+ <div><dt>OVERDRIVE</dt><dd>{loadout.overdrive}{loadout.fixedKit ? " · FIXED KIT" : ""}</dd></div>
129+ <div><dt>FUEL / GP</dt><dd>{loadout.fuelGpPlan}</dd></div>
117130 </dl>
131+ <ul className="skell-package-notes">
132+ {loadout.packageNotes.map((note) => <li key={note}>{note}</li>)}
133+ </ul>
118134 {alternatives.length > 0 && <div className="skell-alts">
119135 <small>{t.skellAlternatives}</small>
120136 {alternatives.map((alt) => (
@@ -3,19 +3,20 @@ import { artDescriptions } from "@/src/data/artDescriptions";
3 import { buildStrategies } from "@/src/data/builds";3 import { buildStrategies } from "@/src/data/builds";
4 import { localizedGameUiText } from "@/src/data/i18n";4 import { localizedGameUiText } from "@/src/data/i18n";
5 import type { Copy } from "@/src/data/i18n";5 import type { Copy } from "@/src/data/i18n";
6+import { recommendContextualSkellLoadout } from "@/src/lib/contextualEngine";
6 import {7 import {
7 localized,8 localized,
8 recommendSkellAlternatives,9 recommendSkellAlternatives,
9 recommendSkellFrame,10 recommendSkellFrame,
10- recommendSkellLoadout,
11 } from "@/src/lib/engine";11 } from "@/src/lib/engine";
12-import type { Lang, RosterMember } from "@/src/lib/types";12+import type { CombatMode, Lang, RosterMember } from "@/src/lib/types";
13 13
14 interface BuildCardProps {14 interface BuildCardProps {
15 index: number;15 index: number;
16 member: RosterMember;16 member: RosterMember;
17 lang: Lang;17 lang: Lang;
18 t: Copy;18 t: Copy;
19+ combatMode: CombatMode;
19 hasSkellLicense: boolean;20 hasSkellLicense: boolean;
20 postgameFrames: boolean;21 postgameFrames: boolean;
21 }22 }
@@ -25,6 +26,7 @@ export default function BuildCard({
25 member,26 member,
26 lang,27 lang,
27 t,28 t,
29+ combatMode,
28 hasSkellLicense,30 hasSkellLicense,
29 postgameFrames,31 postgameFrames,
30 }: BuildCardProps) {32 }: BuildCardProps) {
@@ -37,7 +39,15 @@ export default function BuildCard({
37 postgameFrames,39 postgameFrames,
38 lang,40 lang,
39 });41 });
40- const loadout = skell ? recommendSkellLoadout(member.strategy, skell.requiredLevel) : null;42+ const loadout = skell
43+ ? recommendContextualSkellLoadout({
44+ strategy: member.strategy,
45+ frame: skell.frame,
46+ frameLevel: skell.requiredLevel,
47+ combatMode,
48+ lang,
49+ })
50+ : null;
41 const alternatives = skell51 const alternatives = skell
42 ? recommendSkellAlternatives({52 ? recommendSkellAlternatives({
43 strategy: member.strategy,53 strategy: member.strategy,
@@ -61,14 +71,15 @@ export default function BuildCard({
61 <p className="weapon-lock">{member.weapons.ranged} + {member.weapons.melee} · {localized(member.note, lang)}</p>71 <p className="weapon-lock">{member.weapons.ranged} + {member.weapons.melee} · {localized(member.note, lang)}</p>
62 {member.arts.length > 0 ? <ol>72 {member.arts.length > 0 ? <ol>
63 {member.arts.slice(0, 8).map((art, artIndex) => (73 {member.arts.slice(0, 8).map((art, artIndex) => (
64- <li key={`${art.source.id}-${art.name}`}>74+ <li className={art.specialUnlock ? "special-art" : undefined} key={`${art.name}-${artIndex}`}>
65 <span>{String(artIndex + 1).padStart(2, "0")}</span>75 <span>{String(artIndex + 1).padStart(2, "0")}</span>
66- <b title={art.reason}>{art.name}</b>76+ <b title={art.reason}>{art.name}{art.specialUnlock && <em className="special-art-badge">{t.specialArt}</em>}</b>
67 <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small>77 <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small>
68 {artDescriptions[art.name] && <p className="art-desc">{localized(artDescriptions[art.name], lang)}</p>}78 {artDescriptions[art.name] && <p className="art-desc">{localized(artDescriptions[art.name], lang)}</p>}
69- <a className="lvl-badge" href={art.source.url} target="_blank" rel="noreferrer" title={`${t.recommendedUpgrade} — ${art.source.citation}`}>79+ {art.fallback && <p className="art-fallback"><strong>{t.fallback}: {art.fallback.name}</strong><span>{art.fallback.reason}</span></p>}
80+ <span className="lvl-badge" title={t.recommendedUpgrade}>
70 {t.levelShort} {art.recommendedLevel}81 {t.levelShort} {art.recommendedLevel}
71- </a>82+ </span>
72 </li>83 </li>
73 ))}84 ))}
74 </ol> : <p className="empty-loadout">{t.noLearnedArts}</p>}85 </ol> : <p className="empty-loadout">{t.noLearnedArts}</p>}
@@ -78,12 +89,12 @@ export default function BuildCard({
78 <h4><span>S</span>{t.skills} · {member.skills.length}/{member.skillSlots}</h4>89 <h4><span>S</span>{t.skills} · {member.skills.length}/{member.skillSlots}</h4>
79 {member.skills.length > 0 ? <div className="chip-list">90 {member.skills.length > 0 ? <div className="chip-list">
80 {member.skills.map((skill) => (91 {member.skills.map((skill) => (
81- <span key={`${skill.source.id}-${skill.name}`}>92+ <span key={skill.name}>
82 <b title={skill.reason}>{skill.name}</b>93 <b title={skill.reason}>{skill.name}</b>
83- <small className="lv-rec" title={t.recommendedUpgrade}>{t.levelShort} {skill.recommendedLevel}</small>94+ <small className="skill-unlock">{lang === "fr" ? "DÉBLOCAGE AU RANG" : "UNLOCK AT RANK"} {skill.unlockRank}</small>
84- <a className="lvl-badge" href={skill.source.url} target="_blank" rel="noreferrer" title={skill.source.citation}>95+ <span className="lvl-badge" title={t.recommendedUpgrade}>
85- {skill.source.id} · {t.personalRankShort} {skill.unlockRank}96+ {t.levelShort} {Math.max(1, Math.min(5, skill.recommendedLevel))}
86- </a>97+ </span>
87 </span>98 </span>
88 ))}99 ))}
89 </div> : <p className="empty-loadout">{t.noLearnedSkills}</p>}100 </div> : <p className="empty-loadout">{t.noLearnedSkills}</p>}
@@ -106,15 +117,20 @@ export default function BuildCard({
106 <h4><span>K</span>{t.skellSection}</h4>117 <h4><span>K</span>{t.skellSection}</h4>
107 {skell && loadout ? <>118 {skell && loadout ? <>
108 <div className="skell-frame">119 <div className="skell-frame">
109- <small>{t.skellFrame}</small>120+ <small>{t.skellFrame} · {loadout.archetype}</small>
110 <strong>{skell.frame}</strong>121 <strong>{skell.frame}</strong>
111 <span>{localized(strategy.skellRole, lang)}</span>122 <span>{localized(strategy.skellRole, lang)}</span>
112 </div>123 </div>
113 <dl className="skell-facts">124 <dl className="skell-facts">
114 <div><dt>{t.requiredPilotLevel}</dt><dd>{t.levelShort} {skell.requiredLevel}</dd></div>125 <div><dt>{t.requiredPilotLevel}</dt><dd>{t.levelShort} {skell.requiredLevel}</dd></div>
115 <div><dt>{t.acquisition}</dt><dd>{localizedGameUiText(skell.acquisition, lang)}</dd></div>126 <div><dt>{t.acquisition}</dt><dd>{localizedGameUiText(skell.acquisition, lang)}</dd></div>
116- <div><dt>{t.reason}</dt><dd>{skell.reason}</dd></div>127+ <div><dt>{t.reason}</dt><dd>{loadout.reason}</dd></div>
128+ <div><dt>OVERDRIVE</dt><dd>{loadout.overdrive}{loadout.fixedKit ? " · FIXED KIT" : ""}</dd></div>
129+ <div><dt>FUEL / GP</dt><dd>{loadout.fuelGpPlan}</dd></div>
117 </dl>130 </dl>
131+ <ul className="skell-package-notes">
132+ {loadout.packageNotes.map((note) => <li key={note}>{note}</li>)}
133+ </ul>
118 {alternatives.length > 0 && <div className="skell-alts">134 {alternatives.length > 0 && <div className="skell-alts">
119 <small>{t.skellAlternatives}</small>135 <small>{t.skellAlternatives}</small>
120 {alternatives.map((alt) => (136 {alternatives.map((alt) => (
modified src/components/ControlPanel.tsx +259 -90
@@ -6,103 +6,272 @@ import type { ClassEntry, Lang } from "@/src/lib/types";
66 /** Class select grouped by route so each option shows which branch it belongs to. */
77 const drifter = classes.find((entry) => entry.tier === 1)!;
88 const branchGroups = [
9- { root: drifter, entries: [drifter] },
10- ...classes
11- .filter((entry) => entry.tier === 2)
12- .map((root) => ({
13- root,
14- entries: classes.filter((entry) =>
15- entry.id === root.id || ancestorIds(entry.id).includes(root.id)),
16- })),
9+ { root: drifter, entries: [drifter] },
10+ ...classes
11+ .filter((entry) => entry.tier === 2)
12+ .map((root) => ({
13+ root,
14+ entries: classes.filter(
15+ (entry) =>
16+ entry.id === root.id || ancestorIds(entry.id).includes(root.id),
17+ ),
18+ })),
1719 ];
1820
1921 interface ControlPanelProps {
20- t: Copy;
21- lang: Lang;
22- avatarLevel: number;
23- activeRank: number;
24- activeClass: string;
25- route: ClassEntry[];
26- selectedClass: ClassEntry;
27- classRanks: Record<string, number>;
28- showRanks: boolean;
29- onAvatarLevelChange: (level: number) => void;
30- onClassChange: (classId: string) => void;
31- onRankChange: (rank: number) => void;
32- onClassRankChange: (classId: string, rank: number) => void;
33- onToggleRanks: () => void;
22+ t: Copy;
23+ lang: Lang;
24+ avatarLevel: number;
25+ activeRank: number;
26+ activeClass: string;
27+ route: ClassEntry[];
28+ selectedClass: ClassEntry;
29+ classRanks: Record<string, number>;
30+ showRanks: boolean;
31+ legalRangedWeapons: readonly string[];
32+ legalMeleeWeapons: readonly string[];
33+ selectedRangedWeapon: string;
34+ selectedMeleeWeapon: string;
35+ onAvatarLevelChange: (level: number) => void;
36+ onClassChange: (classId: string) => void;
37+ onRankChange: (rank: number) => void;
38+ onClassRankChange: (classId: string, rank: number) => void;
39+ onRangedWeaponChange: (weapon: string) => void;
40+ onMeleeWeaponChange: (weapon: string) => void;
41+ onToggleRanks: () => void;
3442 }
3543
3644 export default function ControlPanel({
37- t, lang, avatarLevel, activeRank, activeClass, route, selectedClass, classRanks, showRanks,
38- onAvatarLevelChange, onClassChange, onRankChange, onClassRankChange, onToggleRanks,
45+ t,
46+ lang,
47+ avatarLevel,
48+ activeRank,
49+ activeClass,
50+ route,
51+ selectedClass,
52+ classRanks,
53+ showRanks,
54+ legalRangedWeapons,
55+ legalMeleeWeapons,
56+ selectedRangedWeapon,
57+ selectedMeleeWeapon,
58+ onAvatarLevelChange,
59+ onClassChange,
60+ onRankChange,
61+ onClassRankChange,
62+ onRangedWeaponChange,
63+ onMeleeWeaponChange,
64+ onToggleRanks,
3965 }: ControlPanelProps) {
40- return (
41- <div className="panel controls-panel">
42- <label className="field-label" htmlFor="avatar-level">{t.avatarLevel}<output>{avatarLevel}</output></label>
43- <div className="level-row">
44- <input id="avatar-level" className="level-slider" type="range" min="1" max="99" value={avatarLevel} onChange={(event) => onAvatarLevelChange(Number(event.target.value))} />
45- <input key={`avatar-input-${avatarLevel}`} className="level-input" type="number" min="1" max="99"
46- step="1" defaultValue={avatarLevel} aria-label={t.avatarLevel}
47- onBlur={(event) => {
48- const value = Math.max(1, Math.min(99, Math.trunc(Number(event.currentTarget.value) || 1)));
49- event.currentTarget.value = String(value);
50- onAvatarLevelChange(value);
51- }} />
52- </div>
53- <div className="scale"><span>01</span><span>20</span><span>40</span><span>60</span><span>80</span><span>99</span></div>
66+ return (
67+ <div className="panel controls-panel">
68+ <label className="field-label" htmlFor="avatar-level">
69+ {t.avatarLevel}
70+ <output>{avatarLevel}</output>
71+ </label>
72+ <div className="level-row">
73+ <input
74+ id="avatar-level"
75+ className="level-slider"
76+ type="range"
77+ min="1"
78+ max="99"
79+ value={avatarLevel}
80+ onChange={(event) => onAvatarLevelChange(Number(event.target.value))}
81+ />
82+ <input
83+ key={`avatar-input-${avatarLevel}`}
84+ className="level-input"
85+ type="number"
86+ min="1"
87+ max="99"
88+ step="1"
89+ defaultValue={avatarLevel}
90+ aria-label={t.avatarLevel}
91+ onBlur={(event) => {
92+ const value = Math.max(
93+ 1,
94+ Math.min(99, Math.trunc(Number(event.currentTarget.value) || 1)),
95+ );
96+ event.currentTarget.value = String(value);
97+ onAvatarLevelChange(value);
98+ }}
99+ />
100+ </div>
101+ <div className="scale">
102+ <span>01</span>
103+ <span>20</span>
104+ <span>40</span>
105+ <span>60</span>
106+ <span>80</span>
107+ <span>99</span>
108+ </div>
54109
55- <div className="field-row">
56- <label><span>{t.activeClass}</span>
57- <select value={activeClass} onChange={(event) => onClassChange(event.target.value)}>
58- {branchGroups.map(({ root, entries }) => (
59- <optgroup key={root.id} label={dual([root.en, root.fr], lang)}>
60- {entries.map((entry) => (
61- <option value={entry.id} key={entry.id}>
62- {dual([entry.en, entry.fr], lang)} · T{entry.tier}
63- </option>
64- ))}
65- </optgroup>
66- ))}
67- </select>
68- </label>
69- <label><span>{t.rank}</span>
70- <select value={activeRank} onChange={(event) => onRankChange(Number(event.target.value))}>
71- {Array.from({ length: 10 }, (_, index) => index + 1).map((value) => <option value={value} key={value}>RANK {value}</option>)}
72- </select>
73- </label>
74- </div>
110+ <div className="field-row">
111+ <label>
112+ <span>{t.activeClass}</span>
113+ <select
114+ value={activeClass}
115+ onChange={(event) => onClassChange(event.target.value)}
116+ >
117+ {branchGroups.map(({ root, entries }) => (
118+ <optgroup key={root.id} label={dual([root.en, root.fr], lang)}>
119+ {entries.map((entry) => (
120+ <option value={entry.id} key={entry.id}>
121+ {dual([entry.en, entry.fr], lang)} · T{entry.tier}
122+ </option>
123+ ))}
124+ </optgroup>
125+ ))}
126+ </select>
127+ </label>
128+ <label>
129+ <span>{t.rank}</span>
130+ <select
131+ value={activeRank}
132+ onChange={(event) => onRankChange(Number(event.target.value))}
133+ >
134+ {Array.from({ length: 10 }, (_, index) => index + 1).map(
135+ (value) => (
136+ <option value={value} key={value}>
137+ RANK {value}
138+ </option>
139+ ),
140+ )}
141+ </select>
142+ </label>
143+ </div>
75144
76- <div className="class-readout">
77- <div><small>{t.hierarchy}</small><p>{route.map((entry) => dual([entry.en, entry.fr], lang)).join(" › ")}</p></div>
78- <div><small>{t.weapons}</small><p>{selectedClass.ranged} <span>+</span> {selectedClass.melee}</p></div>
79- </div>
80- <button className="ranks-toggle" onClick={onToggleRanks} aria-expanded={showRanks}>
81- <span>{t.allClassRanks}</span><b>{showRanks ? "−" : "+"}</b>
82- </button>
83- {showRanks && <div className="rank-tree">
84- <p className="rank-hint">{t.classRanksHint}</p>
85- {classTree().map((entry) => (
86- <div className={`rank-row${entry.id === activeClass ? " active" : ""}`} key={entry.id} style={{ marginLeft: (entry.tier - 1) * 18 }}>
87- <span className="rank-twig">{entry.tier > 1 ? "└" : ""}</span>
88- <span className="rank-name">{dual([entry.en, entry.fr], lang)}<small className="rank-tier-tag">T{entry.tier}</small></span>
89- <span className="rank-stepper">
90- <button type="button" aria-label={t.rankDown} onClick={() => onClassRankChange(entry.id, Math.max(0, (classRanks[entry.id] ?? 0) - 1))}>−</button>
91- <input key={`${entry.id}-${classRanks[entry.id] ?? 0}`} className="rank-value" type="number"
92- min="0" max="10" step="1" aria-label={`${entry.en} rank`}
93- defaultValue={classRanks[entry.id] ?? 0}
94- onBlur={(event) => {
95- const minimum = entry.id === activeClass ? 1 : 0;
96- const requested = Math.max(minimum, Math.min(10, Math.trunc(Number(event.currentTarget.value) || 0)));
97- const accepted = editClassRank(classRanks, entry.id, requested)[entry.id] ?? 0;
98- event.currentTarget.value = String(accepted);
99- onClassRankChange(entry.id, requested);
100- }} />
101- <button type="button" aria-label={t.rankUp} onClick={() => onClassRankChange(entry.id, Math.min(10, (classRanks[entry.id] ?? 0) + 1))}>+</button>
102- </span>
103- </div>
104- ))}
105- </div>}
106- </div>
107- );
145+ <div className="class-readout">
146+ <div>
147+ <small>{t.hierarchy}</small>
148+ <p>
149+ {route
150+ .map((entry) => dual([entry.en, entry.fr], lang))
151+ .join(" › ")}
152+ </p>
153+ </div>
154+ <div>
155+ <small>{t.weapons}</small>
156+ <p>
157+ {selectedClass.ranged} <span>+</span> {selectedClass.melee}
158+ </p>
159+ </div>
160+ </div>
161+
162+ <fieldset className="weapon-controls">
163+ <legend>{t.weaponLoadout}</legend>
164+ <div className="weapon-field-row">
165+ <label>
166+ <span>{t.rangedWeapon}</span>
167+ <select
168+ value={selectedRangedWeapon}
169+ onChange={(event) => onRangedWeaponChange(event.target.value)}
170+ >
171+ <option value="">{t.autoWeapon}</option>
172+ {legalRangedWeapons.map((weapon) => (
173+ <option value={weapon} key={weapon}>
174+ {weapon}
175+ </option>
176+ ))}
177+ </select>
178+ </label>
179+ <label>
180+ <span>{t.meleeWeapon}</span>
181+ <select
182+ value={selectedMeleeWeapon}
183+ onChange={(event) => onMeleeWeaponChange(event.target.value)}
184+ >
185+ <option value="">{t.autoWeapon}</option>
186+ {legalMeleeWeapons.map((weapon) => (
187+ <option value={weapon} key={weapon}>
188+ {weapon}
189+ </option>
190+ ))}
191+ </select>
192+ </label>
193+ </div>
194+ <p>{t.weaponOverrideHint}</p>
195+ </fieldset>
196+
197+ <button
198+ className="ranks-toggle"
199+ onClick={onToggleRanks}
200+ aria-expanded={showRanks}
201+ >
202+ <span>{t.allClassRanks}</span>
203+ <b>{showRanks ? "−" : "+"}</b>
204+ </button>
205+ {showRanks && (
206+ <div className="rank-tree">
207+ <p className="rank-hint">{t.classRanksHint}</p>
208+ {classTree().map((entry) => (
209+ <div
210+ className={`rank-row${entry.id === activeClass ? " active" : ""}`}
211+ key={entry.id}
212+ style={{ marginLeft: (entry.tier - 1) * 18 }}
213+ >
214+ <span className="rank-twig">{entry.tier > 1 ? "└" : ""}</span>
215+ <span className="rank-name">
216+ {dual([entry.en, entry.fr], lang)}
217+ <small className="rank-tier-tag">T{entry.tier}</small>
218+ </span>
219+ <span className="rank-stepper">
220+ <button
221+ type="button"
222+ aria-label={t.rankDown}
223+ onClick={() =>
224+ onClassRankChange(
225+ entry.id,
226+ Math.max(0, (classRanks[entry.id] ?? 0) - 1),
227+ )
228+ }
229+ >
230+ −
231+ </button>
232+ <input
233+ key={`${entry.id}-${classRanks[entry.id] ?? 0}`}
234+ className="rank-value"
235+ type="number"
236+ min="0"
237+ max="10"
238+ step="1"
239+ aria-label={`${entry.en} rank`}
240+ defaultValue={classRanks[entry.id] ?? 0}
241+ onBlur={(event) => {
242+ const minimum = entry.id === activeClass ? 1 : 0;
243+ const requested = Math.max(
244+ minimum,
245+ Math.min(
246+ 10,
247+ Math.trunc(Number(event.currentTarget.value) || 0),
248+ ),
249+ );
250+ const accepted =
251+ editClassRank(classRanks, entry.id, requested)[
252+ entry.id
253+ ] ?? 0;
254+ event.currentTarget.value = String(accepted);
255+ onClassRankChange(entry.id, requested);
256+ }}
257+ />
258+ <button
259+ type="button"
260+ aria-label={t.rankUp}
261+ onClick={() =>
262+ onClassRankChange(
263+ entry.id,
264+ Math.min(10, (classRanks[entry.id] ?? 0) + 1),
265+ )
266+ }
267+ >
268+ +
269+ </button>
270+ </span>
271+ </div>
272+ ))}
273+ </div>
274+ )}
275+ </div>
276+ );
108277 }
@@ -6,103 +6,272 @@ import type { ClassEntry, Lang } from "@/src/lib/types";
6 /** Class select grouped by route so each option shows which branch it belongs to. */6 /** Class select grouped by route so each option shows which branch it belongs to. */
7 const drifter = classes.find((entry) => entry.tier === 1)!;7 const drifter = classes.find((entry) => entry.tier === 1)!;
8 const branchGroups = [8 const branchGroups = [
9- { root: drifter, entries: [drifter] },9+ { root: drifter, entries: [drifter] },
10- ...classes10+ ...classes
11- .filter((entry) => entry.tier === 2)11+ .filter((entry) => entry.tier === 2)
12- .map((root) => ({12+ .map((root) => ({
13- root,13+ root,
14- entries: classes.filter((entry) =>14+ entries: classes.filter(
15- entry.id === root.id || ancestorIds(entry.id).includes(root.id)),15+ (entry) =>
16- })),16+ entry.id === root.id || ancestorIds(entry.id).includes(root.id),
17+ ),
18+ })),
17 ];19 ];
18 20
19 interface ControlPanelProps {21 interface ControlPanelProps {
20- t: Copy;22+ t: Copy;
21- lang: Lang;23+ lang: Lang;
22- avatarLevel: number;24+ avatarLevel: number;
23- activeRank: number;25+ activeRank: number;
24- activeClass: string;26+ activeClass: string;
25- route: ClassEntry[];27+ route: ClassEntry[];
26- selectedClass: ClassEntry;28+ selectedClass: ClassEntry;
27- classRanks: Record<string, number>;29+ classRanks: Record<string, number>;
28- showRanks: boolean;30+ showRanks: boolean;
29- onAvatarLevelChange: (level: number) => void;31+ legalRangedWeapons: readonly string[];
30- onClassChange: (classId: string) => void;32+ legalMeleeWeapons: readonly string[];
31- onRankChange: (rank: number) => void;33+ selectedRangedWeapon: string;
32- onClassRankChange: (classId: string, rank: number) => void;34+ selectedMeleeWeapon: string;
33- onToggleRanks: () => void;35+ onAvatarLevelChange: (level: number) => void;
36+ onClassChange: (classId: string) => void;
37+ onRankChange: (rank: number) => void;
38+ onClassRankChange: (classId: string, rank: number) => void;
39+ onRangedWeaponChange: (weapon: string) => void;
40+ onMeleeWeaponChange: (weapon: string) => void;
41+ onToggleRanks: () => void;
34 }42 }
35 43
36 export default function ControlPanel({44 export default function ControlPanel({
37- t, lang, avatarLevel, activeRank, activeClass, route, selectedClass, classRanks, showRanks,45+ t,
38- onAvatarLevelChange, onClassChange, onRankChange, onClassRankChange, onToggleRanks,46+ lang,
47+ avatarLevel,
48+ activeRank,
49+ activeClass,
50+ route,
51+ selectedClass,
52+ classRanks,
53+ showRanks,
54+ legalRangedWeapons,
55+ legalMeleeWeapons,
56+ selectedRangedWeapon,
57+ selectedMeleeWeapon,
58+ onAvatarLevelChange,
59+ onClassChange,
60+ onRankChange,
61+ onClassRankChange,
62+ onRangedWeaponChange,
63+ onMeleeWeaponChange,
64+ onToggleRanks,
39 }: ControlPanelProps) {65 }: ControlPanelProps) {
40- return (66+ return (
41- <div className="panel controls-panel">67+ <div className="panel controls-panel">
42- <label className="field-label" htmlFor="avatar-level">{t.avatarLevel}<output>{avatarLevel}</output></label>68+ <label className="field-label" htmlFor="avatar-level">
43- <div className="level-row">69+ {t.avatarLevel}
44- <input id="avatar-level" className="level-slider" type="range" min="1" max="99" value={avatarLevel} onChange={(event) => onAvatarLevelChange(Number(event.target.value))} />70+ <output>{avatarLevel}</output>
45- <input key={`avatar-input-${avatarLevel}`} className="level-input" type="number" min="1" max="99"71+ </label>
46- step="1" defaultValue={avatarLevel} aria-label={t.avatarLevel}72+ <div className="level-row">
47- onBlur={(event) => {73+ <input
48- const value = Math.max(1, Math.min(99, Math.trunc(Number(event.currentTarget.value) || 1)));74+ id="avatar-level"
49- event.currentTarget.value = String(value);75+ className="level-slider"
50- onAvatarLevelChange(value);76+ type="range"
51- }} />77+ min="1"
52- </div>78+ max="99"
53- <div className="scale"><span>01</span><span>20</span><span>40</span><span>60</span><span>80</span><span>99</span></div>79+ value={avatarLevel}
80+ onChange={(event) => onAvatarLevelChange(Number(event.target.value))}
81+ />
82+ <input
83+ key={`avatar-input-${avatarLevel}`}
84+ className="level-input"
85+ type="number"
86+ min="1"
87+ max="99"
88+ step="1"
89+ defaultValue={avatarLevel}
90+ aria-label={t.avatarLevel}
91+ onBlur={(event) => {
92+ const value = Math.max(
93+ 1,
94+ Math.min(99, Math.trunc(Number(event.currentTarget.value) || 1)),
95+ );
96+ event.currentTarget.value = String(value);
97+ onAvatarLevelChange(value);
98+ }}
99+ />
100+ </div>
101+ <div className="scale">
102+ <span>01</span>
103+ <span>20</span>
104+ <span>40</span>
105+ <span>60</span>
106+ <span>80</span>
107+ <span>99</span>
108+ </div>
54 109
55- <div className="field-row">110+ <div className="field-row">
56- <label><span>{t.activeClass}</span>111+ <label>
57- <select value={activeClass} onChange={(event) => onClassChange(event.target.value)}>112+ <span>{t.activeClass}</span>
58- {branchGroups.map(({ root, entries }) => (113+ <select
59- <optgroup key={root.id} label={dual([root.en, root.fr], lang)}>114+ value={activeClass}
60- {entries.map((entry) => (115+ onChange={(event) => onClassChange(event.target.value)}
61- <option value={entry.id} key={entry.id}>116+ >
62- {dual([entry.en, entry.fr], lang)} · T{entry.tier}117+ {branchGroups.map(({ root, entries }) => (
63- </option>118+ <optgroup key={root.id} label={dual([root.en, root.fr], lang)}>
64- ))}119+ {entries.map((entry) => (
65- </optgroup>120+ <option value={entry.id} key={entry.id}>
66- ))}121+ {dual([entry.en, entry.fr], lang)} · T{entry.tier}
67- </select>122+ </option>
68- </label>123+ ))}
69- <label><span>{t.rank}</span>124+ </optgroup>
70- <select value={activeRank} onChange={(event) => onRankChange(Number(event.target.value))}>125+ ))}
71- {Array.from({ length: 10 }, (_, index) => index + 1).map((value) => <option value={value} key={value}>RANK {value}</option>)}126+ </select>
72- </select>127+ </label>
73- </label>128+ <label>
74- </div>129+ <span>{t.rank}</span>
130+ <select
131+ value={activeRank}
132+ onChange={(event) => onRankChange(Number(event.target.value))}
133+ >
134+ {Array.from({ length: 10 }, (_, index) => index + 1).map(
135+ (value) => (
136+ <option value={value} key={value}>
137+ RANK {value}
138+ </option>
139+ ),
140+ )}
141+ </select>
142+ </label>
143+ </div>
75 144
76- <div className="class-readout">145+ <div className="class-readout">
77- <div><small>{t.hierarchy}</small><p>{route.map((entry) => dual([entry.en, entry.fr], lang)).join(" › ")}</p></div>146+ <div>
78- <div><small>{t.weapons}</small><p>{selectedClass.ranged} <span>+</span> {selectedClass.melee}</p></div>147+ <small>{t.hierarchy}</small>
79- </div>148+ <p>
80- <button className="ranks-toggle" onClick={onToggleRanks} aria-expanded={showRanks}>149+ {route
81- <span>{t.allClassRanks}</span><b>{showRanks ? "−" : "+"}</b>150+ .map((entry) => dual([entry.en, entry.fr], lang))
82- </button>151+ .join(" › ")}
83- {showRanks && <div className="rank-tree">152+ </p>
84- <p className="rank-hint">{t.classRanksHint}</p>153+ </div>
85- {classTree().map((entry) => (154+ <div>
86- <div className={`rank-row${entry.id === activeClass ? " active" : ""}`} key={entry.id} style={{ marginLeft: (entry.tier - 1) * 18 }}>155+ <small>{t.weapons}</small>
87- <span className="rank-twig">{entry.tier > 1 ? "└" : ""}</span>156+ <p>
88- <span className="rank-name">{dual([entry.en, entry.fr], lang)}<small className="rank-tier-tag">T{entry.tier}</small></span>157+ {selectedClass.ranged} <span>+</span> {selectedClass.melee}
89- <span className="rank-stepper">158+ </p>
90- <button type="button" aria-label={t.rankDown} onClick={() => onClassRankChange(entry.id, Math.max(0, (classRanks[entry.id] ?? 0) - 1))}>−</button>159+ </div>
91- <input key={`${entry.id}-${classRanks[entry.id] ?? 0}`} className="rank-value" type="number"160+ </div>
92- min="0" max="10" step="1" aria-label={`${entry.en} rank`}161+
93- defaultValue={classRanks[entry.id] ?? 0}162+ <fieldset className="weapon-controls">
94- onBlur={(event) => {163+ <legend>{t.weaponLoadout}</legend>
95- const minimum = entry.id === activeClass ? 1 : 0;164+ <div className="weapon-field-row">
96- const requested = Math.max(minimum, Math.min(10, Math.trunc(Number(event.currentTarget.value) || 0)));165+ <label>
97- const accepted = editClassRank(classRanks, entry.id, requested)[entry.id] ?? 0;166+ <span>{t.rangedWeapon}</span>
98- event.currentTarget.value = String(accepted);167+ <select
99- onClassRankChange(entry.id, requested);168+ value={selectedRangedWeapon}
100- }} />169+ onChange={(event) => onRangedWeaponChange(event.target.value)}
101- <button type="button" aria-label={t.rankUp} onClick={() => onClassRankChange(entry.id, Math.min(10, (classRanks[entry.id] ?? 0) + 1))}>+</button>170+ >
102- </span>171+ <option value="">{t.autoWeapon}</option>
103- </div>172+ {legalRangedWeapons.map((weapon) => (
104- ))}173+ <option value={weapon} key={weapon}>
105- </div>}174+ {weapon}
106- </div>175+ </option>
107- );176+ ))}
177+ </select>
178+ </label>
179+ <label>
180+ <span>{t.meleeWeapon}</span>
181+ <select
182+ value={selectedMeleeWeapon}
183+ onChange={(event) => onMeleeWeaponChange(event.target.value)}
184+ >
185+ <option value="">{t.autoWeapon}</option>
186+ {legalMeleeWeapons.map((weapon) => (
187+ <option value={weapon} key={weapon}>
188+ {weapon}
189+ </option>
190+ ))}
191+ </select>
192+ </label>
193+ </div>
194+ <p>{t.weaponOverrideHint}</p>
195+ </fieldset>
196+
197+ <button
198+ className="ranks-toggle"
199+ onClick={onToggleRanks}
200+ aria-expanded={showRanks}
201+ >
202+ <span>{t.allClassRanks}</span>
203+ <b>{showRanks ? "−" : "+"}</b>
204+ </button>
205+ {showRanks && (
206+ <div className="rank-tree">
207+ <p className="rank-hint">{t.classRanksHint}</p>
208+ {classTree().map((entry) => (
209+ <div
210+ className={`rank-row${entry.id === activeClass ? " active" : ""}`}
211+ key={entry.id}
212+ style={{ marginLeft: (entry.tier - 1) * 18 }}
213+ >
214+ <span className="rank-twig">{entry.tier > 1 ? "└" : ""}</span>
215+ <span className="rank-name">
216+ {dual([entry.en, entry.fr], lang)}
217+ <small className="rank-tier-tag">T{entry.tier}</small>
218+ </span>
219+ <span className="rank-stepper">
220+ <button
221+ type="button"
222+ aria-label={t.rankDown}
223+ onClick={() =>
224+ onClassRankChange(
225+ entry.id,
226+ Math.max(0, (classRanks[entry.id] ?? 0) - 1),
227+ )
228+ }
229+ >
230+ −
231+ </button>
232+ <input
233+ key={`${entry.id}-${classRanks[entry.id] ?? 0}`}
234+ className="rank-value"
235+ type="number"
236+ min="0"
237+ max="10"
238+ step="1"
239+ aria-label={`${entry.en} rank`}
240+ defaultValue={classRanks[entry.id] ?? 0}
241+ onBlur={(event) => {
242+ const minimum = entry.id === activeClass ? 1 : 0;
243+ const requested = Math.max(
244+ minimum,
245+ Math.min(
246+ 10,
247+ Math.trunc(Number(event.currentTarget.value) || 0),
248+ ),
249+ );
250+ const accepted =
251+ editClassRank(classRanks, entry.id, requested)[
252+ entry.id
253+ ] ?? 0;
254+ event.currentTarget.value = String(accepted);
255+ onClassRankChange(entry.id, requested);
256+ }}
257+ />
258+ <button
259+ type="button"
260+ aria-label={t.rankUp}
261+ onClick={() =>
262+ onClassRankChange(
263+ entry.id,
264+ Math.min(10, (classRanks[entry.id] ?? 0) + 1),
265+ )
266+ }
267+ >
268+ +
269+ </button>
270+ </span>
271+ </div>
272+ ))}
273+ </div>
274+ )}
275+ </div>
276+ );
108 }277 }
modified src/components/ResultsSection.tsx +94 -18
@@ -1,39 +1,82 @@
11 import { useState } from "react";
2-import type { Copy } from "@/src/data/i18n";
32 import BuildCard from "@/src/components/BuildCard";
3+import type { Copy } from "@/src/data/i18n";
44 import { dual } from "@/src/lib/engine";
5-import type { ClassEntry, Lang, RosterMember, TeamResolution } from "@/src/lib/types";
5+import type {
6+ ClassEntry,
7+ CombatMode,
8+ ContextualRecommendationResult,
9+ Lang,
10+ RecommendationVariant,
11+} from "@/src/lib/types";
612
713 interface ResultsSectionProps {
814 t: Copy;
915 lang: Lang;
1016 avatarLevel: number;
1117 route: ClassEntry[];
12- roster: RosterMember[];
13- resolvedTeam: TeamResolution;
18+ result: ContextualRecommendationResult;
19+ combatMode: CombatMode;
1420 hasSkellLicense: boolean;
1521 postgameFrames: boolean;
1622 }
1723
24+function labelForVariant(variant: RecommendationVariant, t: Copy): string {
25+ if (variant === "offensive") return t.offensiveParty;
26+ if (variant === "safe") return t.safeParty;
27+ return t.primaryParty;
28+}
1829
1930 export default function ResultsSection({
2031 t,
2132 lang,
2233 avatarLevel,
2334 route,
24- roster,
25- resolvedTeam,
35+ result,
36+ combatMode,
2637 hasSkellLicense,
2738 postgameFrames,
2839 }: ResultsSectionProps) {
29- const avatar = roster[0]!;
40+ const [activeRecommendation, setActiveRecommendation] = useState(0);
3041 const [activeTab, setActiveTab] = useState(0);
31- const currentIndex = Math.min(activeTab, roster.length - 1);
32- const currentMember = roster[currentIndex]!;
33- const resolvedNames = resolvedTeam.slots
42+ const recommendations = result.kind === "success" ? result.recommendations : [];
43+ const currentRecommendationIndex = Math.min(activeRecommendation, Math.max(0, recommendations.length - 1));
44+ const recommendation = recommendations[currentRecommendationIndex];
45+ const currentIndex = recommendation
46+ ? Math.min(activeTab, recommendation.roster.length - 1)
47+ : 0;
48+ const currentMember = recommendation?.roster[currentIndex];
49+ const combatModeLabel = combatMode === "skell" ? t.skellMode : t.generalMode;
50+ const { eyebrow: resultsEyebrow, heading: resultsHeading } = {
51+ general: { eyebrow: t.resultsEyebrowGeneral, heading: t.resultsHeading },
52+ skell: { eyebrow: t.resultsEyebrowSkell, heading: t.resultsHeadingSkell },
53+ }[combatMode];
54+
55+ if (result.kind === "infeasible") {
56+ return (
57+ <section className="results is-ready" id="results">
58+ <div className="section-heading results-heading">
59+ <span className="step">02</span>
60+ <div><p>{resultsEyebrow}</p><h2>{t.infeasible}</h2></div>
61+ <div className="result-meta"><b>SQUAD LV. {avatarLevel}</b></div>
62+ </div>
63+ <article className="infeasible-panel">
64+ <div><small>{t.violations}</small><ul>{result.violations.map((item) => <li key={item}>{item}</li>)}</ul></div>
65+ <div><small>{t.relaxations}</small><ul>{result.relaxations.map((item) => <li key={item}>{item}</li>)}</ul></div>
66+ </article>
67+ <aside className="method-note">
68+ <span>!</span><div><h3>{t.methodTitle}</h3><p>{t.methodBody}</p></div>
69+ </aside>
70+ </section>
71+ );
72+ }
73+
74+ if (!recommendation || !currentMember) return null;
75+ const avatar = recommendation.roster[0]!;
76+ const resolvedNames = recommendation.team.slots
3477 .map((slot) => slot.name || t.noEligibleOperator)
3578 .join(" · ");
36- const selectionDetails = resolvedTeam.slots
79+ const selectionDetails = recommendation.team.slots
3780 .map((slot, index) =>
3881 `0${index + 1} ${slot.provenance === "manual" ? t.manualPick : t.autoPick}: ${slot.name || t.noEligibleOperator}`)
3982 .join(" · ");
@@ -42,10 +85,28 @@ export default function ResultsSection({
4285 <section className="results is-ready" id="results">
4386 <div className="section-heading results-heading">
4487 <span className="step">02</span>
45- <div><p>TACTICAL OUTPUT</p><h2>{t.resultsHeading}</h2></div>
46- <div className="result-meta"><b>SQUAD LV. {avatarLevel}</b></div>
88+ <div><p>{resultsEyebrow}</p><h2>{resultsHeading}</h2></div>
89+ <div className="result-meta"><b>SQUAD LV. {avatarLevel}</b><span>{combatModeLabel.toUpperCase()}</span></div>
4790 </div>
4891
92+ <div className="recommendation-tabs" role="tablist" aria-label={resultsHeading}>
93+ {recommendations.map((entry, index) => (
94+ <button
95+ key={entry.variant}
96+ role="tab"
97+ aria-selected={index === currentRecommendationIndex}
98+ className={index === currentRecommendationIndex ? "active" : undefined}
99+ onClick={() => {
100+ setActiveRecommendation(index);
101+ setActiveTab(0);
102+ }}
103+ >
104+ <span>{labelForVariant(entry.variant, t)}</span>
105+ <b>{entry.team.names.join(" · ")}</b>
106+ <small>{t.scoring} {entry.score.total}</small>
107+ </button>
108+ ))}
109+ </div>
49110
50111 <div className="squad-summary">
51112 <div>
@@ -54,19 +115,33 @@ export default function ResultsSection({
54115 <span>RANK {avatar.rank} · {avatar.weapons.ranged} + {avatar.weapons.melee}</span>
55116 </div>
56117 <div>
57- <small>{t.teamMode} · {resolvedTeam.mode.toUpperCase()}</small>
118+ <small>{t.teamMode} · {recommendation.team.mode.toUpperCase()} · {combatModeLabel.toUpperCase()}</small>
58119 <strong>{resolvedNames}</strong>
59120 <span>{selectionDetails}</span>
60121 </div>
61122 <div className="coverage-summary">
62123 <small>{t.coverage}</small>
63- <strong>TANK {resolvedTeam.coverage.tank} · SUPPORT {resolvedTeam.coverage.support}</strong>
64- <span>DAMAGE {resolvedTeam.coverage.damage} · CONTROL {resolvedTeam.coverage.control}</span>
124+ <strong>TANK {recommendation.team.coverage.tank} · SUPPORT {recommendation.team.coverage.support}</strong>
125+ <span>DAMAGE {recommendation.team.coverage.damage} · CONTROL {recommendation.team.coverage.control}</span>
65126 </div>
66127 </div>
67128
68- <div className="build-tabs" role="tablist" aria-label={t.resultsHeading}>
69- {roster.map((member, index) => (
129+ <aside className="recommendation-context">
130+ <div>
131+ <small>{t.recommendationReasons}</small>
132+ <ul>{recommendation.reasons.map((reason) => <li key={reason}>{reason}</li>)}</ul>
133+ </div>
134+ <dl className="score-grid">
135+ <div><dt>{t.confidence}</dt><dd>{recommendation.confidence.toUpperCase()}</dd></div>
136+ <div><dt>BAL</dt><dd>{recommendation.score.balance}</dd></div>
137+ <div><dt>ATK</dt><dd>{recommendation.score.offense}</dd></div>
138+ <div><dt>SAFE</dt><dd>{recommendation.score.safety}</dd></div>
139+ <div><dt>SKELL</dt><dd>{recommendation.score.skell}</dd></div>
140+ </dl>
141+ </aside>
142+
143+ <div className="build-tabs" role="tablist" aria-label={resultsHeading}>
144+ {recommendation.roster.map((member, index) => (
70145 <button
71146 key={`${member.kind}-${member.name}`}
72147 role="tab"
@@ -89,6 +164,7 @@ export default function ResultsSection({
89164 member={currentMember}
90165 lang={lang}
91166 t={t}
167+ combatMode={combatMode}
92168 hasSkellLicense={hasSkellLicense}
93169 postgameFrames={postgameFrames}
94170 />
@@ -1,39 +1,82 @@
1 import { useState } from "react";1 import { useState } from "react";
2-import type { Copy } from "@/src/data/i18n";
3 import BuildCard from "@/src/components/BuildCard";2 import BuildCard from "@/src/components/BuildCard";
3+import type { Copy } from "@/src/data/i18n";
4 import { dual } from "@/src/lib/engine";4 import { dual } from "@/src/lib/engine";
5-import type { ClassEntry, Lang, RosterMember, TeamResolution } from "@/src/lib/types";5+import type {
6+ ClassEntry,
7+ CombatMode,
8+ ContextualRecommendationResult,
9+ Lang,
10+ RecommendationVariant,
11+} from "@/src/lib/types";
6 12
7 interface ResultsSectionProps {13 interface ResultsSectionProps {
8 t: Copy;14 t: Copy;
9 lang: Lang;15 lang: Lang;
10 avatarLevel: number;16 avatarLevel: number;
11 route: ClassEntry[];17 route: ClassEntry[];
12- roster: RosterMember[];18+ result: ContextualRecommendationResult;
13- resolvedTeam: TeamResolution;19+ combatMode: CombatMode;
14 hasSkellLicense: boolean;20 hasSkellLicense: boolean;
15 postgameFrames: boolean;21 postgameFrames: boolean;
16 }22 }
17 23
24+function labelForVariant(variant: RecommendationVariant, t: Copy): string {
25+ if (variant === "offensive") return t.offensiveParty;
26+ if (variant === "safe") return t.safeParty;
27+ return t.primaryParty;
28+}
18 29
19 export default function ResultsSection({30 export default function ResultsSection({
20 t,31 t,
21 lang,32 lang,
22 avatarLevel,33 avatarLevel,
23 route,34 route,
24- roster,35+ result,
25- resolvedTeam,36+ combatMode,
26 hasSkellLicense,37 hasSkellLicense,
27 postgameFrames,38 postgameFrames,
28 }: ResultsSectionProps) {39 }: ResultsSectionProps) {
29- const avatar = roster[0]!;40+ const [activeRecommendation, setActiveRecommendation] = useState(0);
30 const [activeTab, setActiveTab] = useState(0);41 const [activeTab, setActiveTab] = useState(0);
31- const currentIndex = Math.min(activeTab, roster.length - 1);42+ const recommendations = result.kind === "success" ? result.recommendations : [];
32- const currentMember = roster[currentIndex]!;43+ const currentRecommendationIndex = Math.min(activeRecommendation, Math.max(0, recommendations.length - 1));
33- const resolvedNames = resolvedTeam.slots44+ const recommendation = recommendations[currentRecommendationIndex];
45+ const currentIndex = recommendation
46+ ? Math.min(activeTab, recommendation.roster.length - 1)
47+ : 0;
48+ const currentMember = recommendation?.roster[currentIndex];
49+ const combatModeLabel = combatMode === "skell" ? t.skellMode : t.generalMode;
50+ const { eyebrow: resultsEyebrow, heading: resultsHeading } = {
51+ general: { eyebrow: t.resultsEyebrowGeneral, heading: t.resultsHeading },
52+ skell: { eyebrow: t.resultsEyebrowSkell, heading: t.resultsHeadingSkell },
53+ }[combatMode];
54+
55+ if (result.kind === "infeasible") {
56+ return (
57+ <section className="results is-ready" id="results">
58+ <div className="section-heading results-heading">
59+ <span className="step">02</span>
60+ <div><p>{resultsEyebrow}</p><h2>{t.infeasible}</h2></div>
61+ <div className="result-meta"><b>SQUAD LV. {avatarLevel}</b></div>
62+ </div>
63+ <article className="infeasible-panel">
64+ <div><small>{t.violations}</small><ul>{result.violations.map((item) => <li key={item}>{item}</li>)}</ul></div>
65+ <div><small>{t.relaxations}</small><ul>{result.relaxations.map((item) => <li key={item}>{item}</li>)}</ul></div>
66+ </article>
67+ <aside className="method-note">
68+ <span>!</span><div><h3>{t.methodTitle}</h3><p>{t.methodBody}</p></div>
69+ </aside>
70+ </section>
71+ );
72+ }
73+
74+ if (!recommendation || !currentMember) return null;
75+ const avatar = recommendation.roster[0]!;
76+ const resolvedNames = recommendation.team.slots
34 .map((slot) => slot.name || t.noEligibleOperator)77 .map((slot) => slot.name || t.noEligibleOperator)
35 .join(" · ");78 .join(" · ");
36- const selectionDetails = resolvedTeam.slots79+ const selectionDetails = recommendation.team.slots
37 .map((slot, index) =>80 .map((slot, index) =>
38 `0${index + 1} ${slot.provenance === "manual" ? t.manualPick : t.autoPick}: ${slot.name || t.noEligibleOperator}`)81 `0${index + 1} ${slot.provenance === "manual" ? t.manualPick : t.autoPick}: ${slot.name || t.noEligibleOperator}`)
39 .join(" · ");82 .join(" · ");
@@ -42,10 +85,28 @@ export default function ResultsSection({
42 <section className="results is-ready" id="results">85 <section className="results is-ready" id="results">
43 <div className="section-heading results-heading">86 <div className="section-heading results-heading">
44 <span className="step">02</span>87 <span className="step">02</span>
45- <div><p>TACTICAL OUTPUT</p><h2>{t.resultsHeading}</h2></div>88+ <div><p>{resultsEyebrow}</p><h2>{resultsHeading}</h2></div>
46- <div className="result-meta"><b>SQUAD LV. {avatarLevel}</b></div>89+ <div className="result-meta"><b>SQUAD LV. {avatarLevel}</b><span>{combatModeLabel.toUpperCase()}</span></div>
47 </div>90 </div>
48 91
92+ <div className="recommendation-tabs" role="tablist" aria-label={resultsHeading}>
93+ {recommendations.map((entry, index) => (
94+ <button
95+ key={entry.variant}
96+ role="tab"
97+ aria-selected={index === currentRecommendationIndex}
98+ className={index === currentRecommendationIndex ? "active" : undefined}
99+ onClick={() => {
100+ setActiveRecommendation(index);
101+ setActiveTab(0);
102+ }}
103+ >
104+ <span>{labelForVariant(entry.variant, t)}</span>
105+ <b>{entry.team.names.join(" · ")}</b>
106+ <small>{t.scoring} {entry.score.total}</small>
107+ </button>
108+ ))}
109+ </div>
49 110
50 <div className="squad-summary">111 <div className="squad-summary">
51 <div>112 <div>
@@ -54,19 +115,33 @@ export default function ResultsSection({
54 <span>RANK {avatar.rank} · {avatar.weapons.ranged} + {avatar.weapons.melee}</span>115 <span>RANK {avatar.rank} · {avatar.weapons.ranged} + {avatar.weapons.melee}</span>
55 </div>116 </div>
56 <div>117 <div>
57- <small>{t.teamMode} · {resolvedTeam.mode.toUpperCase()}</small>118+ <small>{t.teamMode} · {recommendation.team.mode.toUpperCase()} · {combatModeLabel.toUpperCase()}</small>
58 <strong>{resolvedNames}</strong>119 <strong>{resolvedNames}</strong>
59 <span>{selectionDetails}</span>120 <span>{selectionDetails}</span>
60 </div>121 </div>
61 <div className="coverage-summary">122 <div className="coverage-summary">
62 <small>{t.coverage}</small>123 <small>{t.coverage}</small>
63- <strong>TANK {resolvedTeam.coverage.tank} · SUPPORT {resolvedTeam.coverage.support}</strong>124+ <strong>TANK {recommendation.team.coverage.tank} · SUPPORT {recommendation.team.coverage.support}</strong>
64- <span>DAMAGE {resolvedTeam.coverage.damage} · CONTROL {resolvedTeam.coverage.control}</span>125+ <span>DAMAGE {recommendation.team.coverage.damage} · CONTROL {recommendation.team.coverage.control}</span>
65 </div>126 </div>
66 </div>127 </div>
67 128
68- <div className="build-tabs" role="tablist" aria-label={t.resultsHeading}>129+ <aside className="recommendation-context">
69- {roster.map((member, index) => (130+ <div>
131+ <small>{t.recommendationReasons}</small>
132+ <ul>{recommendation.reasons.map((reason) => <li key={reason}>{reason}</li>)}</ul>
133+ </div>
134+ <dl className="score-grid">
135+ <div><dt>{t.confidence}</dt><dd>{recommendation.confidence.toUpperCase()}</dd></div>
136+ <div><dt>BAL</dt><dd>{recommendation.score.balance}</dd></div>
137+ <div><dt>ATK</dt><dd>{recommendation.score.offense}</dd></div>
138+ <div><dt>SAFE</dt><dd>{recommendation.score.safety}</dd></div>
139+ <div><dt>SKELL</dt><dd>{recommendation.score.skell}</dd></div>
140+ </dl>
141+ </aside>
142+
143+ <div className="build-tabs" role="tablist" aria-label={resultsHeading}>
144+ {recommendation.roster.map((member, index) => (
70 <button145 <button
71 key={`${member.kind}-${member.name}`}146 key={`${member.kind}-${member.name}`}
72 role="tab"147 role="tab"
@@ -89,6 +164,7 @@ export default function ResultsSection({
89 member={currentMember}164 member={currentMember}
90 lang={lang}165 lang={lang}
91 t={t}166 t={t}
167+ combatMode={combatMode}
92 hasSkellLicense={hasSkellLicense}168 hasSkellLicense={hasSkellLicense}
93 postgameFrames={postgameFrames}169 postgameFrames={postgameFrames}
94 />170 />
modified src/components/SquadPanel.tsx +19 -2
@@ -1,5 +1,6 @@
11 import { companions } from "@/src/data/companions";
22 import type { Copy } from "@/src/data/i18n";
3+import type { CombatMode } from "@/src/lib/types";
34
45 /** Companions listed alphabetically in slot selects. */
56 const sortedCompanions = [...companions].sort((left, right) =>
@@ -11,12 +12,16 @@ interface SquadPanelProps {
1112 resolvedTeamNames: readonly [string, string, string];
1213 effectiveLevel: number;
1314 personalRanks: readonly [number, number, number];
15+ combatMode: CombatMode;
1416 hasSkellLicense: boolean;
1517 postgameFrames: boolean;
18+ assumeSpecialUnlocks: boolean;
1619 onUpdateMember: (index: number, value: string) => void;
1720 onUpdateRank: (index: number, value: number) => void;
21+ onCombatModeChange: (value: CombatMode) => void;
1822 onHasSkellLicenseChange: (value: boolean) => void;
1923 onPostgameFramesChange: (value: boolean) => void;
24+ onAssumeSpecialUnlocksChange: (value: boolean) => void;
2025 onViewResults: () => void;
2126 }
2227
@@ -26,12 +31,16 @@ export default function SquadPanel({
2631 resolvedTeamNames,
2732 effectiveLevel,
2833 personalRanks,
34+ combatMode,
2935 hasSkellLicense,
3036 postgameFrames,
37+ assumeSpecialUnlocks,
3138 onUpdateMember,
3239 onUpdateRank,
40+ onCombatModeChange,
3341 onHasSkellLicenseChange,
3442 onPostgameFramesChange,
43+ onAssumeSpecialUnlocksChange,
3544 onViewResults,
3645 }: SquadPanelProps) {
3746 return (
@@ -70,10 +79,18 @@ export default function SquadPanel({
7079 })}
7180 </div>
7281
73- <fieldset className="progression-controls">
74- <legend>{t.skellProgression}</legend>
82+ <fieldset className="progression-controls contextual-controls">
83+ <legend>{t.combatContext}</legend>
84+ <label className="combat-mode-field">
85+ <span>{t.combatMode}</span>
86+ <select value={combatMode} onChange={(event) => onCombatModeChange(event.target.value as CombatMode)}>
87+ <option value="general">{t.generalMode}</option>
88+ <option value="skell">{t.skellMode}</option>
89+ </select>
90+ </label>
7591 <label><input type="checkbox" checked={hasSkellLicense} onChange={(event) => onHasSkellLicenseChange(event.target.checked)} />{t.skellLicense}</label>
7692 <label className={!hasSkellLicense ? "disabled" : undefined}><input type="checkbox" checked={postgameFrames} disabled={!hasSkellLicense} onChange={(event) => onPostgameFramesChange(event.target.checked)} />{t.postgameFrames}</label>
93+ <label className="special-unlock-field" title={t.specialUnlockHint}><input type="checkbox" checked={assumeSpecialUnlocks} onChange={(event) => onAssumeSpecialUnlocksChange(event.target.checked)} />{t.specialUnlocks}</label>
7794 </fieldset>
7895
7996 <button className="generate-button" onClick={onViewResults}>
@@ -1,5 +1,6 @@
1 import { companions } from "@/src/data/companions";1 import { companions } from "@/src/data/companions";
2 import type { Copy } from "@/src/data/i18n";2 import type { Copy } from "@/src/data/i18n";
3+import type { CombatMode } from "@/src/lib/types";
3 4
4 /** Companions listed alphabetically in slot selects. */5 /** Companions listed alphabetically in slot selects. */
5 const sortedCompanions = [...companions].sort((left, right) =>6 const sortedCompanions = [...companions].sort((left, right) =>
@@ -11,12 +12,16 @@ interface SquadPanelProps {
11 resolvedTeamNames: readonly [string, string, string];12 resolvedTeamNames: readonly [string, string, string];
12 effectiveLevel: number;13 effectiveLevel: number;
13 personalRanks: readonly [number, number, number];14 personalRanks: readonly [number, number, number];
15+ combatMode: CombatMode;
14 hasSkellLicense: boolean;16 hasSkellLicense: boolean;
15 postgameFrames: boolean;17 postgameFrames: boolean;
18+ assumeSpecialUnlocks: boolean;
16 onUpdateMember: (index: number, value: string) => void;19 onUpdateMember: (index: number, value: string) => void;
17 onUpdateRank: (index: number, value: number) => void;20 onUpdateRank: (index: number, value: number) => void;
21+ onCombatModeChange: (value: CombatMode) => void;
18 onHasSkellLicenseChange: (value: boolean) => void;22 onHasSkellLicenseChange: (value: boolean) => void;
19 onPostgameFramesChange: (value: boolean) => void;23 onPostgameFramesChange: (value: boolean) => void;
24+ onAssumeSpecialUnlocksChange: (value: boolean) => void;
20 onViewResults: () => void;25 onViewResults: () => void;
21 }26 }
22 27
@@ -26,12 +31,16 @@ export default function SquadPanel({
26 resolvedTeamNames,31 resolvedTeamNames,
27 effectiveLevel,32 effectiveLevel,
28 personalRanks,33 personalRanks,
34+ combatMode,
29 hasSkellLicense,35 hasSkellLicense,
30 postgameFrames,36 postgameFrames,
37+ assumeSpecialUnlocks,
31 onUpdateMember,38 onUpdateMember,
32 onUpdateRank,39 onUpdateRank,
40+ onCombatModeChange,
33 onHasSkellLicenseChange,41 onHasSkellLicenseChange,
34 onPostgameFramesChange,42 onPostgameFramesChange,
43+ onAssumeSpecialUnlocksChange,
35 onViewResults,44 onViewResults,
36 }: SquadPanelProps) {45 }: SquadPanelProps) {
37 return (46 return (
@@ -70,10 +79,18 @@ export default function SquadPanel({
70 })}79 })}
71 </div>80 </div>
72 81
73- <fieldset className="progression-controls">82+ <fieldset className="progression-controls contextual-controls">
74- <legend>{t.skellProgression}</legend>83+ <legend>{t.combatContext}</legend>
84+ <label className="combat-mode-field">
85+ <span>{t.combatMode}</span>
86+ <select value={combatMode} onChange={(event) => onCombatModeChange(event.target.value as CombatMode)}>
87+ <option value="general">{t.generalMode}</option>
88+ <option value="skell">{t.skellMode}</option>
89+ </select>
90+ </label>
75 <label><input type="checkbox" checked={hasSkellLicense} onChange={(event) => onHasSkellLicenseChange(event.target.checked)} />{t.skellLicense}</label>91 <label><input type="checkbox" checked={hasSkellLicense} onChange={(event) => onHasSkellLicenseChange(event.target.checked)} />{t.skellLicense}</label>
76 <label className={!hasSkellLicense ? "disabled" : undefined}><input type="checkbox" checked={postgameFrames} disabled={!hasSkellLicense} onChange={(event) => onPostgameFramesChange(event.target.checked)} />{t.postgameFrames}</label>92 <label className={!hasSkellLicense ? "disabled" : undefined}><input type="checkbox" checked={postgameFrames} disabled={!hasSkellLicense} onChange={(event) => onPostgameFramesChange(event.target.checked)} />{t.postgameFrames}</label>
93+ <label className="special-unlock-field" title={t.specialUnlockHint}><input type="checkbox" checked={assumeSpecialUnlocks} onChange={(event) => onAssumeSpecialUnlocksChange(event.target.checked)} />{t.specialUnlocks}</label>
77 </fieldset>94 </fieldset>
78 95
79 <button className="generate-button" onClick={onViewResults}>96 <button className="generate-button" onClick={onViewResults}>
modified src/components/TeamBuilder.tsx +235 -139
@@ -1,161 +1,257 @@
11 "use client";
22
33 import { useEffect, useMemo, useState } from "react";
4-import { classes } from "@/src/data/classes";
5-import { copy } from "@/src/data/i18n";
6-import {
7- buildRoster,
8- classRanksForActiveClass,
9- classRoute,
10- editClassRank,
11- normalizeClassRanks,
12- resolveTeam,
13-} from "@/src/lib/engine";
14-import type { Lang } from "@/src/lib/types";
154 import ControlPanel from "@/src/components/ControlPanel";
165 import ResultsSection from "@/src/components/ResultsSection";
176 import SiteFooter from "@/src/components/SiteFooter";
187 import SquadPanel from "@/src/components/SquadPanel";
198 import Topbar from "@/src/components/Topbar";
9+import { classes } from "@/src/data/classes";
10+import { copy } from "@/src/data/i18n";
11+import { recommendContextualTeams } from "@/src/lib/contextualEngine";
12+import {
13+ classRanksForActiveClass,
14+ classRoute,
15+ editClassRank,
16+ legalAvatarWeaponPairs,
17+ normalizeClassRanks,
18+} from "@/src/lib/engine";
19+import type { CombatMode, Lang, WeaponOverrides } from "@/src/lib/types";
2020
2121 export default function TeamBuilder() {
22- const [lang, setLang] = useState<Lang>("en");
23- const [avatarLevel, setAvatarLevel] = useState(30);
24- const [activeClass, setActiveClass] = useState("duelist");
25- const [team, setTeam] = useState<[string, string, string]>(["", "", ""]);
26- const [personalRanks, setPersonalRanks] = useState<[number, number, number]>([10, 10, 10]);
27- const [classRanks, setClassRanks] = useState<Record<string, number>>(() =>
28- normalizeClassRanks({
29- drifter: 10,
30- striker: 10,
31- "samurai-gunner": 10,
32- duelist: 10,
33- }),
34- );
35- const [showRanks, setShowRanks] = useState(false);
36- const [hasSkellLicense, setHasSkellLicense] = useState(false);
37- const [postgameFrames, setPostgameFrames] = useState(false);
22+ const [lang, setLang] = useState<Lang>("en");
23+ const [avatarLevel, setAvatarLevel] = useState(30);
24+ const [activeClass, setActiveClass] = useState("duelist");
25+ const [team, setTeam] = useState<[string, string, string]>(["", "", ""]);
26+ const [personalRanks, setPersonalRanks] = useState<[number, number, number]>([
27+ 20, 20, 20,
28+ ]);
29+ const [classRanks, setClassRanks] = useState<Record<string, number>>(() =>
30+ normalizeClassRanks({
31+ drifter: 10,
32+ striker: 10,
33+ "samurai-gunner": 10,
34+ duelist: 10,
35+ }),
36+ );
37+ const [showRanks, setShowRanks] = useState(false);
38+ const [rangedWeapon, setRangedWeapon] = useState("");
39+ const [meleeWeapon, setMeleeWeapon] = useState("");
40+ const [combatMode, setCombatMode] = useState<CombatMode>("general");
41+ const [hasSkellLicense, setHasSkellLicense] = useState(false);
42+ const [postgameFrames, setPostgameFrames] = useState(false);
43+ const [assumeSpecialUnlocks, setAssumeSpecialUnlocks] = useState(false);
3844
39- const t = copy[lang];
40- useEffect(() => {
41- document.documentElement.lang = lang;
42- }, [lang]);
43- const selectedClass = classes.find((entry) => entry.id === activeClass)!;
44- const activeRank = classRanks[activeClass] ?? 0;
45- const route = useMemo(() => classRoute(selectedClass), [selectedClass]);
46- const resolvedTeam = useMemo(
47- () => resolveTeam(team, activeClass, avatarLevel),
48- [team, activeClass, avatarLevel],
49- );
50- const roster = buildRoster(
51- avatarLevel,
52- activeClass,
53- classRanks,
54- resolvedTeam,
55- personalRanks,
56- lang,
57- );
45+ const t = copy[lang];
46+ useEffect(() => {
47+ document.documentElement.lang = lang;
48+ }, [lang]);
49+ const selectedClass = classes.find((entry) => entry.id === activeClass)!;
50+ const activeRank = classRanks[activeClass] ?? 0;
51+ const route = useMemo(() => classRoute(selectedClass), [selectedClass]);
52+ const legalWeaponPairs = useMemo(
53+ () => legalAvatarWeaponPairs(activeClass, classRanks),
54+ [activeClass, classRanks],
55+ );
56+ const legalRangedWeapons = useMemo(
57+ () => [...new Set(legalWeaponPairs.map((pair) => pair.ranged))].sort(),
58+ [legalWeaponPairs],
59+ );
60+ const legalMeleeWeapons = useMemo(
61+ () => [...new Set(legalWeaponPairs.map((pair) => pair.melee))].sort(),
62+ [legalWeaponPairs],
63+ );
64+ const selectedRangedWeapon = legalRangedWeapons.includes(rangedWeapon)
65+ ? rangedWeapon
66+ : "";
67+ const selectedMeleeWeapon = legalMeleeWeapons.includes(meleeWeapon)
68+ ? meleeWeapon
69+ : "";
70+ const weaponOverrides = useMemo<WeaponOverrides | undefined>(() => {
71+ if (!selectedRangedWeapon && !selectedMeleeWeapon) return undefined;
72+ return {
73+ ...(selectedRangedWeapon ? { ranged: selectedRangedWeapon } : {}),
74+ ...(selectedMeleeWeapon ? { melee: selectedMeleeWeapon } : {}),
75+ };
76+ }, [selectedRangedWeapon, selectedMeleeWeapon]);
77+ const contextualResult = useMemo(
78+ () =>
79+ recommendContextualTeams({
80+ slots: team,
81+ activeClassId: activeClass,
82+ avatarLevel,
83+ classRanks,
84+ weaponOverrides,
85+ personalRanks,
86+ combatMode,
87+ hasSkellLicense,
88+ postgameFrames,
89+ assumeSpecialUnlocks,
90+ lang,
91+ }),
92+ [
93+ team,
94+ activeClass,
95+ avatarLevel,
96+ classRanks,
97+ weaponOverrides,
98+ personalRanks,
99+ combatMode,
100+ hasSkellLicense,
101+ postgameFrames,
102+ assumeSpecialUnlocks,
103+ lang,
104+ ],
105+ );
106+ const primaryRecommendation =
107+ contextualResult.kind === "success"
108+ ? (contextualResult.recommendations.find(
109+ (entry) => entry.variant === "primary",
110+ ) ?? contextualResult.recommendations[0])
111+ : undefined;
112+ const resolvedTeamNames: [string, string, string] = primaryRecommendation
113+ ?.team.names ?? ["", "", ""];
58114
59- function handleClassChange(classId: string) {
60- setClassRanks((current) => classRanksForActiveClass(classId, current));
61- setActiveClass(classId);
62- }
115+ function handleClassChange(classId: string) {
116+ setClassRanks((current) => classRanksForActiveClass(classId, current));
117+ setActiveClass(classId);
118+ setRangedWeapon("");
119+ setMeleeWeapon("");
120+ }
63121
64- function handleClassRankChange(classId: string, value: number) {
65- const minimum = classId === activeClass ? 1 : 0;
66- const next = editClassRank(classRanks, classId, Math.max(minimum, Math.min(10, value)));
67- let nextActive = activeClass;
68- if ((next[nextActive] ?? 0) < 1) nextActive = "drifter";
69- next[nextActive] = Math.max(1, next[nextActive] ?? 0);
70- setClassRanks(next);
71- if (nextActive !== activeClass) setActiveClass(nextActive);
72- }
122+ function handleClassRankChange(classId: string, value: number) {
123+ const minimum = classId === activeClass ? 1 : 0;
124+ const next = editClassRank(
125+ classRanks,
126+ classId,
127+ Math.max(minimum, Math.min(10, value)),
128+ );
129+ let nextActive = activeClass;
130+ if ((next[nextActive] ?? 0) < 1) nextActive = "drifter";
131+ next[nextActive] = Math.max(1, next[nextActive] ?? 0);
132+ const nextWeaponPairs = legalAvatarWeaponPairs(nextActive, next);
133+ if (
134+ rangedWeapon &&
135+ !nextWeaponPairs.some((pair) => pair.ranged === rangedWeapon)
136+ ) {
137+ setRangedWeapon("");
138+ }
139+ if (
140+ meleeWeapon &&
141+ !nextWeaponPairs.some((pair) => pair.melee === meleeWeapon)
142+ ) {
143+ setMeleeWeapon("");
144+ }
145+ setClassRanks(next);
146+ if (nextActive !== activeClass) setActiveClass(nextActive);
147+ }
73148
74- function updateMember(index: number, value: string) {
75- setTeam((current) =>
76- current.map((member, slot) => slot === index ? value : member) as [
77- string,
78- string,
79- string,
80- ],
81- );
82- }
149+ function updateMember(index: number, value: string) {
150+ setTeam(
151+ (current) =>
152+ current.map((member, slot) => (slot === index ? value : member)) as [
153+ string,
154+ string,
155+ string,
156+ ],
157+ );
158+ }
83159
84- function updatePersonalRank(index: number, value: number) {
85- setPersonalRanks((current) =>
86- current.map((rank, slot) => slot === index ? value : rank) as [
87- number,
88- number,
89- number,
90- ],
91- );
92- }
160+ function updatePersonalRank(index: number, value: number) {
161+ setPersonalRanks(
162+ (current) =>
163+ current.map((rank, slot) => (slot === index ? value : rank)) as [
164+ number,
165+ number,
166+ number,
167+ ],
168+ );
169+ }
93170
94- function viewResults() {
95- window.setTimeout(() => document.getElementById("results")?.scrollIntoView({ behavior: "smooth", block: "start" }), 40);
96- }
171+ function viewResults() {
172+ window.setTimeout(
173+ () =>
174+ document
175+ .getElementById("results")
176+ ?.scrollIntoView({ behavior: "smooth", block: "start" }),
177+ 40,
178+ );
179+ }
97180
98- return (
99- <main>
100- <div className="scanlines" aria-hidden="true" />
101- <Topbar t={t} lang={lang} onLangChange={setLang} />
181+ return (
182+ <main>
183+ <div className="scanlines" aria-hidden="true" />
184+ <Topbar t={t} lang={lang} onLangChange={setLang} />
102185
103- <section className="builder-shell" aria-label="Team configuration">
104- <div className="section-heading">
105- <span className="step">01</span>
106- <div><p>MISSION PARAMETERS</p><h2>{t.builderHeading}</h2></div>
107- <span className="section-code">XCX-DE // MIRA</span>
108- </div>
186+ <section className="builder-shell" aria-label="Team configuration">
187+ <div className="section-heading">
188+ <span className="step">01</span>
189+ <div>
190+ <p>MISSION PARAMETERS</p>
191+ <h2>{t.builderHeading}</h2>
192+ </div>
193+ <span className="section-code">XCX-DE // MIRA</span>
194+ </div>
109195
110- <div className="builder-grid">
111- <ControlPanel
112- t={t}
113- lang={lang}
114- avatarLevel={avatarLevel}
115- activeRank={activeRank}
116- activeClass={activeClass}
117- route={route}
118- selectedClass={selectedClass}
119- classRanks={classRanks}
120- showRanks={showRanks}
121- onAvatarLevelChange={setAvatarLevel}
122- onClassChange={handleClassChange}
123- onRankChange={(value) => handleClassRankChange(activeClass, value)}
124- onClassRankChange={handleClassRankChange}
125- onToggleRanks={() => setShowRanks((value) => !value)}
126- />
127- <SquadPanel
128- t={t}
129- team={team}
130- resolvedTeamNames={resolvedTeam.names}
131- effectiveLevel={avatarLevel}
132- personalRanks={personalRanks}
133- hasSkellLicense={hasSkellLicense}
134- postgameFrames={postgameFrames}
135- onUpdateMember={updateMember}
136- onUpdateRank={updatePersonalRank}
137- onHasSkellLicenseChange={(value) => {
138- setHasSkellLicense(value);
139- if (!value) setPostgameFrames(false);
140- }}
141- onPostgameFramesChange={setPostgameFrames}
142- onViewResults={viewResults}
143- />
144- </div>
145- </section>
196+ <div className="builder-grid">
197+ <ControlPanel
198+ t={t}
199+ lang={lang}
200+ avatarLevel={avatarLevel}
201+ activeRank={activeRank}
202+ activeClass={activeClass}
203+ route={route}
204+ selectedClass={selectedClass}
205+ classRanks={classRanks}
206+ showRanks={showRanks}
207+ legalRangedWeapons={legalRangedWeapons}
208+ legalMeleeWeapons={legalMeleeWeapons}
209+ selectedRangedWeapon={selectedRangedWeapon}
210+ selectedMeleeWeapon={selectedMeleeWeapon}
211+ onAvatarLevelChange={setAvatarLevel}
212+ onClassChange={handleClassChange}
213+ onRankChange={(value) => handleClassRankChange(activeClass, value)}
214+ onClassRankChange={handleClassRankChange}
215+ onRangedWeaponChange={setRangedWeapon}
216+ onMeleeWeaponChange={setMeleeWeapon}
217+ onToggleRanks={() => setShowRanks((value) => !value)}
218+ />
219+ <SquadPanel
220+ t={t}
221+ team={team}
222+ resolvedTeamNames={resolvedTeamNames}
223+ effectiveLevel={avatarLevel}
224+ personalRanks={personalRanks}
225+ combatMode={combatMode}
226+ hasSkellLicense={hasSkellLicense}
227+ postgameFrames={postgameFrames}
228+ assumeSpecialUnlocks={assumeSpecialUnlocks}
229+ onUpdateMember={updateMember}
230+ onUpdateRank={updatePersonalRank}
231+ onCombatModeChange={setCombatMode}
232+ onHasSkellLicenseChange={(value) => {
233+ setHasSkellLicense(value);
234+ if (!value) setPostgameFrames(false);
235+ }}
236+ onPostgameFramesChange={setPostgameFrames}
237+ onAssumeSpecialUnlocksChange={setAssumeSpecialUnlocks}
238+ onViewResults={viewResults}
239+ />
240+ </div>
241+ </section>
146242
147- <ResultsSection
148- t={t}
149- lang={lang}
150- avatarLevel={avatarLevel}
151- route={route}
152- roster={roster}
153- resolvedTeam={resolvedTeam}
154- hasSkellLicense={hasSkellLicense}
155- postgameFrames={hasSkellLicense && postgameFrames}
156- />
243+ <ResultsSection
244+ t={t}
245+ lang={lang}
246+ avatarLevel={avatarLevel}
247+ route={route}
248+ result={contextualResult}
249+ combatMode={combatMode}
250+ hasSkellLicense={hasSkellLicense}
251+ postgameFrames={hasSkellLicense && postgameFrames}
252+ />
157253
158- <SiteFooter t={t} />
159- </main>
160- );
254+ <SiteFooter t={t} />
255+ </main>
256+ );
161257 }
@@ -1,161 +1,257 @@
1 "use client";1 "use client";
2 2
3 import { useEffect, useMemo, useState } from "react";3 import { useEffect, useMemo, useState } from "react";
4-import { classes } from "@/src/data/classes";
5-import { copy } from "@/src/data/i18n";
6-import {
7- buildRoster,
8- classRanksForActiveClass,
9- classRoute,
10- editClassRank,
11- normalizeClassRanks,
12- resolveTeam,
13-} from "@/src/lib/engine";
14-import type { Lang } from "@/src/lib/types";
15 import ControlPanel from "@/src/components/ControlPanel";4 import ControlPanel from "@/src/components/ControlPanel";
16 import ResultsSection from "@/src/components/ResultsSection";5 import ResultsSection from "@/src/components/ResultsSection";
17 import SiteFooter from "@/src/components/SiteFooter";6 import SiteFooter from "@/src/components/SiteFooter";
18 import SquadPanel from "@/src/components/SquadPanel";7 import SquadPanel from "@/src/components/SquadPanel";
19 import Topbar from "@/src/components/Topbar";8 import Topbar from "@/src/components/Topbar";
9+import { classes } from "@/src/data/classes";
10+import { copy } from "@/src/data/i18n";
11+import { recommendContextualTeams } from "@/src/lib/contextualEngine";
12+import {
13+ classRanksForActiveClass,
14+ classRoute,
15+ editClassRank,
16+ legalAvatarWeaponPairs,
17+ normalizeClassRanks,
18+} from "@/src/lib/engine";
19+import type { CombatMode, Lang, WeaponOverrides } from "@/src/lib/types";
20 20
21 export default function TeamBuilder() {21 export default function TeamBuilder() {
22- const [lang, setLang] = useState<Lang>("en");22+ const [lang, setLang] = useState<Lang>("en");
23- const [avatarLevel, setAvatarLevel] = useState(30);23+ const [avatarLevel, setAvatarLevel] = useState(30);
24- const [activeClass, setActiveClass] = useState("duelist");24+ const [activeClass, setActiveClass] = useState("duelist");
25- const [team, setTeam] = useState<[string, string, string]>(["", "", ""]);25+ const [team, setTeam] = useState<[string, string, string]>(["", "", ""]);
26- const [personalRanks, setPersonalRanks] = useState<[number, number, number]>([10, 10, 10]);26+ const [personalRanks, setPersonalRanks] = useState<[number, number, number]>([
27- const [classRanks, setClassRanks] = useState<Record<string, number>>(() =>27+ 20, 20, 20,
28- normalizeClassRanks({28+ ]);
29- drifter: 10,29+ const [classRanks, setClassRanks] = useState<Record<string, number>>(() =>
30- striker: 10,30+ normalizeClassRanks({
31- "samurai-gunner": 10,31+ drifter: 10,
32- duelist: 10,32+ striker: 10,
33- }),33+ "samurai-gunner": 10,
34- );34+ duelist: 10,
35- const [showRanks, setShowRanks] = useState(false);35+ }),
36- const [hasSkellLicense, setHasSkellLicense] = useState(false);36+ );
37- const [postgameFrames, setPostgameFrames] = useState(false);37+ const [showRanks, setShowRanks] = useState(false);
38+ const [rangedWeapon, setRangedWeapon] = useState("");
39+ const [meleeWeapon, setMeleeWeapon] = useState("");
40+ const [combatMode, setCombatMode] = useState<CombatMode>("general");
41+ const [hasSkellLicense, setHasSkellLicense] = useState(false);
42+ const [postgameFrames, setPostgameFrames] = useState(false);
43+ const [assumeSpecialUnlocks, setAssumeSpecialUnlocks] = useState(false);
38 44
39- const t = copy[lang];45+ const t = copy[lang];
40- useEffect(() => {46+ useEffect(() => {
41- document.documentElement.lang = lang;47+ document.documentElement.lang = lang;
42- }, [lang]);48+ }, [lang]);
43- const selectedClass = classes.find((entry) => entry.id === activeClass)!;49+ const selectedClass = classes.find((entry) => entry.id === activeClass)!;
44- const activeRank = classRanks[activeClass] ?? 0;50+ const activeRank = classRanks[activeClass] ?? 0;
45- const route = useMemo(() => classRoute(selectedClass), [selectedClass]);51+ const route = useMemo(() => classRoute(selectedClass), [selectedClass]);
46- const resolvedTeam = useMemo(52+ const legalWeaponPairs = useMemo(
47- () => resolveTeam(team, activeClass, avatarLevel),53+ () => legalAvatarWeaponPairs(activeClass, classRanks),
48- [team, activeClass, avatarLevel],54+ [activeClass, classRanks],
49- );55+ );
50- const roster = buildRoster(56+ const legalRangedWeapons = useMemo(
51- avatarLevel,57+ () => [...new Set(legalWeaponPairs.map((pair) => pair.ranged))].sort(),
52- activeClass,58+ [legalWeaponPairs],
53- classRanks,59+ );
54- resolvedTeam,60+ const legalMeleeWeapons = useMemo(
55- personalRanks,61+ () => [...new Set(legalWeaponPairs.map((pair) => pair.melee))].sort(),
56- lang,62+ [legalWeaponPairs],
57- );63+ );
64+ const selectedRangedWeapon = legalRangedWeapons.includes(rangedWeapon)
65+ ? rangedWeapon
66+ : "";
67+ const selectedMeleeWeapon = legalMeleeWeapons.includes(meleeWeapon)
68+ ? meleeWeapon
69+ : "";
70+ const weaponOverrides = useMemo<WeaponOverrides | undefined>(() => {
71+ if (!selectedRangedWeapon && !selectedMeleeWeapon) return undefined;
72+ return {
73+ ...(selectedRangedWeapon ? { ranged: selectedRangedWeapon } : {}),
74+ ...(selectedMeleeWeapon ? { melee: selectedMeleeWeapon } : {}),
75+ };
76+ }, [selectedRangedWeapon, selectedMeleeWeapon]);
77+ const contextualResult = useMemo(
78+ () =>
79+ recommendContextualTeams({
80+ slots: team,
81+ activeClassId: activeClass,
82+ avatarLevel,
83+ classRanks,
84+ weaponOverrides,
85+ personalRanks,
86+ combatMode,
87+ hasSkellLicense,
88+ postgameFrames,
89+ assumeSpecialUnlocks,
90+ lang,
91+ }),
92+ [
93+ team,
94+ activeClass,
95+ avatarLevel,
96+ classRanks,
97+ weaponOverrides,
98+ personalRanks,
99+ combatMode,
100+ hasSkellLicense,
101+ postgameFrames,
102+ assumeSpecialUnlocks,
103+ lang,
104+ ],
105+ );
106+ const primaryRecommendation =
107+ contextualResult.kind === "success"
108+ ? (contextualResult.recommendations.find(
109+ (entry) => entry.variant === "primary",
110+ ) ?? contextualResult.recommendations[0])
111+ : undefined;
112+ const resolvedTeamNames: [string, string, string] = primaryRecommendation
113+ ?.team.names ?? ["", "", ""];
58 114
59- function handleClassChange(classId: string) {115+ function handleClassChange(classId: string) {
60- setClassRanks((current) => classRanksForActiveClass(classId, current));116+ setClassRanks((current) => classRanksForActiveClass(classId, current));
61- setActiveClass(classId);117+ setActiveClass(classId);
62- }118+ setRangedWeapon("");
119+ setMeleeWeapon("");
120+ }
63 121
64- function handleClassRankChange(classId: string, value: number) {122+ function handleClassRankChange(classId: string, value: number) {
65- const minimum = classId === activeClass ? 1 : 0;123+ const minimum = classId === activeClass ? 1 : 0;
66- const next = editClassRank(classRanks, classId, Math.max(minimum, Math.min(10, value)));124+ const next = editClassRank(
67- let nextActive = activeClass;125+ classRanks,
68- if ((next[nextActive] ?? 0) < 1) nextActive = "drifter";126+ classId,
69- next[nextActive] = Math.max(1, next[nextActive] ?? 0);127+ Math.max(minimum, Math.min(10, value)),
70- setClassRanks(next);128+ );
71- if (nextActive !== activeClass) setActiveClass(nextActive);129+ let nextActive = activeClass;
72- }130+ if ((next[nextActive] ?? 0) < 1) nextActive = "drifter";
131+ next[nextActive] = Math.max(1, next[nextActive] ?? 0);
132+ const nextWeaponPairs = legalAvatarWeaponPairs(nextActive, next);
133+ if (
134+ rangedWeapon &&
135+ !nextWeaponPairs.some((pair) => pair.ranged === rangedWeapon)
136+ ) {
137+ setRangedWeapon("");
138+ }
139+ if (
140+ meleeWeapon &&
141+ !nextWeaponPairs.some((pair) => pair.melee === meleeWeapon)
142+ ) {
143+ setMeleeWeapon("");
144+ }
145+ setClassRanks(next);
146+ if (nextActive !== activeClass) setActiveClass(nextActive);
147+ }
73 148
74- function updateMember(index: number, value: string) {149+ function updateMember(index: number, value: string) {
75- setTeam((current) =>150+ setTeam(
76- current.map((member, slot) => slot === index ? value : member) as [151+ (current) =>
77- string,152+ current.map((member, slot) => (slot === index ? value : member)) as [
78- string,153+ string,
79- string,154+ string,
80- ],155+ string,
81- );156+ ],
82- }157+ );
158+ }
83 159
84- function updatePersonalRank(index: number, value: number) {160+ function updatePersonalRank(index: number, value: number) {
85- setPersonalRanks((current) =>161+ setPersonalRanks(
86- current.map((rank, slot) => slot === index ? value : rank) as [162+ (current) =>
87- number,163+ current.map((rank, slot) => (slot === index ? value : rank)) as [
88- number,164+ number,
89- number,165+ number,
90- ],166+ number,
91- );167+ ],
92- }168+ );
169+ }
93 170
94- function viewResults() {171+ function viewResults() {
95- window.setTimeout(() => document.getElementById("results")?.scrollIntoView({ behavior: "smooth", block: "start" }), 40);172+ window.setTimeout(
96- }173+ () =>
174+ document
175+ .getElementById("results")
176+ ?.scrollIntoView({ behavior: "smooth", block: "start" }),
177+ 40,
178+ );
179+ }
97 180
98- return (181+ return (
99- <main>182+ <main>
100- <div className="scanlines" aria-hidden="true" />183+ <div className="scanlines" aria-hidden="true" />
101- <Topbar t={t} lang={lang} onLangChange={setLang} />184+ <Topbar t={t} lang={lang} onLangChange={setLang} />
102 185
103- <section className="builder-shell" aria-label="Team configuration">186+ <section className="builder-shell" aria-label="Team configuration">
104- <div className="section-heading">187+ <div className="section-heading">
105- <span className="step">01</span>188+ <span className="step">01</span>
106- <div><p>MISSION PARAMETERS</p><h2>{t.builderHeading}</h2></div>189+ <div>
107- <span className="section-code">XCX-DE // MIRA</span>190+ <p>MISSION PARAMETERS</p>
108- </div>191+ <h2>{t.builderHeading}</h2>
192+ </div>
193+ <span className="section-code">XCX-DE // MIRA</span>
194+ </div>
109 195
110- <div className="builder-grid">196+ <div className="builder-grid">
111- <ControlPanel197+ <ControlPanel
112- t={t}198+ t={t}
113- lang={lang}199+ lang={lang}
114- avatarLevel={avatarLevel}200+ avatarLevel={avatarLevel}
115- activeRank={activeRank}201+ activeRank={activeRank}
116- activeClass={activeClass}202+ activeClass={activeClass}
117- route={route}203+ route={route}
118- selectedClass={selectedClass}204+ selectedClass={selectedClass}
119- classRanks={classRanks}205+ classRanks={classRanks}
120- showRanks={showRanks}206+ showRanks={showRanks}
121- onAvatarLevelChange={setAvatarLevel}207+ legalRangedWeapons={legalRangedWeapons}
122- onClassChange={handleClassChange}208+ legalMeleeWeapons={legalMeleeWeapons}
123- onRankChange={(value) => handleClassRankChange(activeClass, value)}209+ selectedRangedWeapon={selectedRangedWeapon}
124- onClassRankChange={handleClassRankChange}210+ selectedMeleeWeapon={selectedMeleeWeapon}
125- onToggleRanks={() => setShowRanks((value) => !value)}211+ onAvatarLevelChange={setAvatarLevel}
126- />212+ onClassChange={handleClassChange}
127- <SquadPanel213+ onRankChange={(value) => handleClassRankChange(activeClass, value)}
128- t={t}214+ onClassRankChange={handleClassRankChange}
129- team={team}215+ onRangedWeaponChange={setRangedWeapon}
130- resolvedTeamNames={resolvedTeam.names}216+ onMeleeWeaponChange={setMeleeWeapon}
131- effectiveLevel={avatarLevel}217+ onToggleRanks={() => setShowRanks((value) => !value)}
132- personalRanks={personalRanks}218+ />
133- hasSkellLicense={hasSkellLicense}219+ <SquadPanel
134- postgameFrames={postgameFrames}220+ t={t}
135- onUpdateMember={updateMember}221+ team={team}
136- onUpdateRank={updatePersonalRank}222+ resolvedTeamNames={resolvedTeamNames}
137- onHasSkellLicenseChange={(value) => {223+ effectiveLevel={avatarLevel}
138- setHasSkellLicense(value);224+ personalRanks={personalRanks}
139- if (!value) setPostgameFrames(false);225+ combatMode={combatMode}
140- }}226+ hasSkellLicense={hasSkellLicense}
141- onPostgameFramesChange={setPostgameFrames}227+ postgameFrames={postgameFrames}
142- onViewResults={viewResults}228+ assumeSpecialUnlocks={assumeSpecialUnlocks}
143- />229+ onUpdateMember={updateMember}
144- </div>230+ onUpdateRank={updatePersonalRank}
145- </section>231+ onCombatModeChange={setCombatMode}
232+ onHasSkellLicenseChange={(value) => {
233+ setHasSkellLicense(value);
234+ if (!value) setPostgameFrames(false);
235+ }}
236+ onPostgameFramesChange={setPostgameFrames}
237+ onAssumeSpecialUnlocksChange={setAssumeSpecialUnlocks}
238+ onViewResults={viewResults}
239+ />
240+ </div>
241+ </section>
146 242
147- <ResultsSection243+ <ResultsSection
148- t={t}244+ t={t}
149- lang={lang}245+ lang={lang}
150- avatarLevel={avatarLevel}246+ avatarLevel={avatarLevel}
151- route={route}247+ route={route}
152- roster={roster}248+ result={contextualResult}
153- resolvedTeam={resolvedTeam}249+ combatMode={combatMode}
154- hasSkellLicense={hasSkellLicense}250+ hasSkellLicense={hasSkellLicense}
155- postgameFrames={hasSkellLicense && postgameFrames}251+ postgameFrames={hasSkellLicense && postgameFrames}
156- />252+ />
157 253
158- <SiteFooter t={t} />254+ <SiteFooter t={t} />
159- </main>255+ </main>
160- );256+ );
161 }257 }
added src/data/contextual.ts +278 -0
new file mode 100644
@@ -0,0 +1,278 @@
1+import type {
2+ ContextualRoleVector,
3+ EvidenceRef,
4+ LocalizedText,
5+ StrategyKey,
6+} from "@/src/lib/types";
7+import type { SkellWeaponPlan } from "@/src/data/skellWeapons";
8+
9+export type StrategyArchetype = "tank" | "damage" | "support" | "hybrid";
10+
11+export const strategyArchetype: Record<StrategyKey, StrategyArchetype> = {
12+ duelist: "damage",
13+ bastion: "tank",
14+ jaguar: "damage",
15+ astral: "damage",
16+ mastermind: "support",
17+ galactic: "hybrid",
18+};
19+
20+/**
21+ * Provisional, deliberately low-impact priors distilled from progression-aware
22+ * DE build guides. Mechanics and role fit always carry more score weight.
23+ */
24+export const companionCommunityPrior: Readonly<Record<string, number>> = {
25+ Irina: 1,
26+ Elma: 0.94,
27+ "H.B.": 0.92,
28+ Nagi: 0.88,
29+ Mia: 0.82,
30+ Celica: 0.78,
31+ Lin: 0.76,
32+ Alexa: 0.72,
33+ Yelv: 0.72,
34+ L: 0.68,
35+ Lao: 0.66,
36+ Doug: 0.64,
37+ Liesel: 0.63,
38+ Hope: 0.62,
39+ Murderess: 0.61,
40+ Gwin: 0.6,
41+ Phog: 0.59,
42+ Neilnail: 0.58,
43+ "Ga Jiarg": 0.57,
44+ Frye: 0.56,
45+ Bozé: 0.55,
46+ Al: 0.54,
47+};
48+
49+export const evidenceCatalog = {
50+ battleArts: {
51+ id: "battle-arts",
52+ label: "Xeno Series Wiki — Battle Art (XCX)",
53+ url: "https://www.xenoserieswiki.org/wiki/Battle_Art_(XCX)",
54+ tier: 2,
55+ edition: "both",
56+ },
57+ skills: {
58+ id: "skills",
59+ label: "Xeno Series Wiki — Skill (XCX)",
60+ url: "https://www.xenoserieswiki.org/wiki/Skill_(XCX)",
61+ tier: 2,
62+ edition: "both",
63+ },
64+ cross: {
65+ id: "cross-gameplay",
66+ label: "Xeno Series Wiki — Cross/Gameplay",
67+ url: "https://www.xenoserieswiki.org/wiki/Cross/Gameplay",
68+ tier: 2,
69+ edition: "both",
70+ },
71+ overdrive: {
72+ id: "overdrive",
73+ label: "Xeno Series Wiki — Overdrive",
74+ url: "https://www.xenoserieswiki.org/wiki/Overdrive_(XCX)",
75+ tier: 2,
76+ edition: "both",
77+ },
78+ skell: {
79+ id: "skell",
80+ label: "Xeno Series Wiki — Skell",
81+ url: "https://www.xenoserieswiki.org/wiki/Skell",
82+ tier: 2,
83+ edition: "both",
84+ },
85+ skellWeapons: {
86+ id: "skell-weapons",
87+ label: "Xeno Series Wiki — Skell weapon",
88+ url: "https://www.xenoserieswiki.org/wiki/Skell_weapon",
89+ tier: 2,
90+ edition: "both",
91+ },
92+ communityBuilds: {
93+ id: "community-builds",
94+ label: "Nintendo Life — DE character builds",
95+ url: "https://www.nintendolife.com/guides/xenoblade-chronicles-x-best-combat-builds-for-all-characters",
96+ tier: 3,
97+ edition: "DE",
98+ },
99+ communityTeams: {
100+ id: "community-teams",
101+ label: "Game8 — DE companion and progression guidance",
102+ url: "https://game8.co/games/Xenoblade-Chronicles-X/archives/506277",
103+ tier: 3,
104+ edition: "DE",
105+ },
106+} as const satisfies Record<string, EvidenceRef>;
107+
108+export const skillRoleSignals: Readonly<
109+ Record<string, Partial<ContextualRoleVector>>
110+> = {
111+ Synchrony: { skell: 3 },
112+ "Stellar Entry": { skell: 1.4 },
113+ "Grand Procession": { support: 1, tp: 2 },
114+ "High Tension": { tp: 1.4 },
115+ "TP Overdrive": { tp: 1.6 },
116+ "Trigger Happy": { tp: 1 },
117+ "Fast Forward": { tp: 1, support: 0.4 },
118+ "Healing Touch": { sustain: 1.4, support: 0.8 },
119+ "King's Decree": { control: 1, support: 0.4 },
120+ "Tactical Analyst": { control: 0.8, support: 0.6 },
121+ "Steel Flesh": { tank: 0.8 },
122+ "Fortified Flesh": { tank: 0.8 },
123+ "Mighty Muscle": { damage: 0.8 },
124+ "Aura Assault": { damage: 0.8 },
125+ "Electric Boost": { damage: 0.6 },
126+ "Conflagrant Edge": { damage: 0.8 },
127+};
128+
129+export interface FrameArchetype {
130+ key: string;
131+ label: LocalizedText;
132+ reason: LocalizedText;
133+ overdrive: string;
134+ fuelGpPlan: LocalizedText;
135+}
136+
137+export const frameArchetypes: readonly FrameArchetype[] = [
138+ {
139+ key: "Amdusias",
140+ label: ["Melee / appendage striker", "Frappeur mêlée / appendices"],
141+ reason: [
142+ "Melee-focused frame and Overdrive profile; prioritize high-force melee and GP tools.",
143+ "Frame et Overdrive orientés mêlée ; privilégier force mêlée et génération de GP.",
144+ ],
145+ overdrive: "Kurenai",
146+ fuelGpPlan: ["Build GP with efficient multi-hit Arts, then use Kurenai for melee fuel absorption.", "Générer les GP avec des Arts multi-coups efficients, puis utiliser Kurenai pour absorber du fuel en mêlée."],
147+ },
148+ {
149+ key: "Lailah",
150+ label: ["Sustain tank", "Tank de sustain"],
151+ reason: [
152+ "Defensive sustain profile; prioritize shields, control, GP, and efficient weapons.",
153+ "Profil défensif durable ; privilégier boucliers, contrôle, GP et armes efficientes.",
154+ ],
155+ overdrive: "Nagi",
156+ fuelGpPlan: ["Reach 3,000 GP with low-cost control Arts; Nagi removes fuel use and sustains long fights.", "Atteindre 3 000 GP avec des Arts de contrôle peu coûteux ; Nagi supprime le coût en fuel et soutient les combats longs."],
157+ },
158+ {
159+ key: "Mastema",
160+ label: ["Beam artillery", "Artillerie laser"],
161+ reason: [
162+ "Ranged/beam profile; prioritize ranged force, beam damage, and appendage utility.",
163+ "Profil distance/laser ; privilégier force à distance, dégâts laser et appendices.",
164+ ],
165+ overdrive: "Gekitetsu",
166+ fuelGpPlan: ["Generate GP before committing high-fuel artillery; Gekitetsu removes fuel use during the burst window.", "Générer les GP avant l'artillerie coûteuse ; Gekitetsu supprime le coût en fuel pendant la fenêtre de burst."],
167+ },
168+ {
169+ key: "Inferno",
170+ label: ["Thermal artillery", "Artillerie thermique"],
171+ reason: [
172+ "Ranged/thermal profile; prioritize thermal AoE, ranged force, and fuel economy.",
173+ "Profil distance/thermique ; privilégier AoE thermique, force à distance et fuel.",
174+ ],
175+ overdrive: "Hibana",
176+ fuelGpPlan: ["Use efficient thermal attacks to reach Overdrive before spending fuel on large AoE weapons.", "Utiliser des attaques thermiques efficientes pour atteindre l'Overdrive avant les grandes armes AoE coûteuses."],
177+ },
178+ {
179+ key: "Hraesvelg",
180+ label: ["Evasion / cooldown striker", "Frappeur évasion / cooldown"],
181+ reason: [
182+ "Accuracy, evasion, and cooldown profile with fixed equipment; evaluate its built-in package as a whole.",
183+ "Profil précision, évasion et cooldown à équipement fixe ; évaluer son package intégré complet.",
184+ ],
185+ overdrive: "Jinrai",
186+ fuelGpPlan: ["Cycle the fixed multi-hit kit for GP, then use Jinrai for fuel-free rapid cooldowns.", "Cycler le kit fixe multi-coups pour les GP, puis utiliser Jinrai pour des cooldowns rapides sans fuel."],
187+ },
188+ {
189+ key: "Formula",
190+ label: ["Agile generalist", "Généraliste agile"],
191+ reason: [
192+ "Agile frame profile; prioritize accurate, fast-cycling weapons and evasion support.",
193+ "Profil agile ; privilégier armes précises, rapides et soutien d'évasion.",
194+ ],
195+ overdrive: "Jinrai",
196+ fuelGpPlan: ["Favor low-cooldown multi-hit Arts to reach Jinrai and maintain fuel-free pressure.", "Privilégier les Arts multi-coups à faible cooldown pour atteindre Jinrai et maintenir la pression sans fuel."],
197+ },
198+ {
199+ key: "Verus",
200+ label: ["Physical melee duelist", "Duelliste mêlée physique"],
201+ reason: [
202+ "Physical melee and reflect profile; prioritize melee burst and defensive utility.",
203+ "Profil mêlée physique et reflect ; privilégier burst mêlée et utilité défensive.",
204+ ],
205+ overdrive: "Hagane",
206+ fuelGpPlan: ["Build GP with low-cost melee Arts, then exploit Hagane's melee boost and physical reflect.", "Générer les GP avec des Arts mêlée peu coûteux, puis exploiter le bonus mêlée et le reflect physique de Hagane."],
207+ },
208+ {
209+ key: "Urban",
210+ label: ["Accessible generalist", "Généraliste accessible"],
211+ reason: [
212+ "License/progression frame; prioritize accessible balanced weapons over specialization.",
213+ "Frame de licence/progression ; privilégier armes équilibrées et accessibles.",
214+ ],
215+ overdrive: "Oboro",
216+ fuelGpPlan: ["Use accessible low-cost weapons to build GP; save large fuel spends for Oboro's zero-fuel window.", "Utiliser des armes accessibles peu coûteuses pour les GP ; réserver les fortes dépenses à la fenêtre sans fuel d'Oboro."],
217+ },
218+ {
219+ key: "Ares",
220+ label: ["Fixed-kit burst", "Burst à kit fixe"],
221+ reason: [
222+ "Fixed burst package; build GP before using its high-fuel Ether weapons inside Legion.",
223+ "Package de burst fixe ; générer les GP avant les armes Éther coûteuses en fuel pendant Legion.",
224+ ],
225+ overdrive: "Legion",
226+ fuelGpPlan: ["Build 3,000 GP with cheaper multi-hit Arts, enter Legion, then fire Aghasura during zero fuel use.", "Générer 3 000 GP avec des Arts multi-coups moins coûteux, activer Legion, puis utiliser Aghasura sans coût de fuel."],
227+ },
228+];
229+
230+export interface FixedSkellKit {
231+ matches: readonly string[];
232+ overdrive: string;
233+ weapons: readonly SkellWeaponPlan[];
234+ augments: readonly string[];
235+ notes: readonly LocalizedText[];
236+ evidence: EvidenceRef;
237+}
238+
239+export const fixedSkellKits: readonly FixedSkellKit[] = [
240+ {
241+ matches: ["Hraesvelg"],
242+ overdrive: "Jinrai",
243+ weapons: [
244+ { name: "Blade-Mode", slot: "sidearm", attribute: "beam", acquisition: "Fixed equipment" },
245+ { name: "Gun-Mode", slot: "sidearm", attribute: "beam", acquisition: "Fixed equipment" },
246+ { name: "G-Slash", slot: "back", attribute: "gravity", acquisition: "Fixed equipment" },
247+ { name: "S-Nova", slot: "shoulder", attribute: "gravity", acquisition: "Fixed equipment" },
248+ { name: "Boost", slot: "shoulder", attribute: "physical", acquisition: "Fixed equipment" },
249+ { name: "Gunform", slot: "arm", attribute: "physical", acquisition: "Fixed equipment" },
250+ { name: "Build", slot: "arm", attribute: "physical", acquisition: "Fixed equipment" },
251+ { name: "Claw-Whip", slot: "spare", attribute: "beam", acquisition: "Fixed equipment" },
252+ ],
253+ augments: ["Custom.WP-ATK", "Arts.GP", "SpecUp.EVA"],
254+ notes: [
255+ ["Mandatory weapons preserved; normal Skell weapons cannot replace this package.", "Armes obligatoires conservées ; les armes Skell normales ne peuvent pas remplacer ce package."],
256+ ["Hunter and W-Blade remain quest-gated and are not assumed in the safe package.", "Hunter et W-Blade restent liés à une quête et ne sont pas supposés dans le package sûr."],
257+ ],
258+ evidence: { id: "hraesvelg-kit", label: "Game8 — Hraesvelg frames", url: "https://game8.co/games/Xenoblade-Chronicles-X/archives/510136", tier: 3, edition: "DE" },
259+ },
260+ {
261+ matches: ["Ares 90", "Ares 70", "Ares Prime"],
262+ overdrive: "Legion",
263+ weapons: [
264+ { name: "M-BLASTER", slot: "sidearm", attribute: "ether", acquisition: "Fixed equipment" },
265+ { name: "M-BLADE", slot: "sidearm", attribute: "ether", acquisition: "Fixed equipment" },
266+ { name: "AGHASURA Cannon", slot: "shoulder", attribute: "ether", acquisition: "Fixed equipment" },
267+ { name: "AGNI Gatling", slot: "back", attribute: "ether", acquisition: "Fixed equipment" },
268+ { name: "VAJRA Flail", slot: "shoulder", attribute: "physical", acquisition: "Fixed equipment" },
269+ { name: "AVATARA Missile", slot: "back", attribute: "thermal", acquisition: "Fixed equipment" },
270+ ],
271+ augments: ["AttributeDmg.ETHER", "Arts.GP", "Custom.WP-ATK"],
272+ notes: [
273+ ["Mandatory Ares weapons preserved; prioritize Aghasura burst inside Legion's zero-fuel window.", "Armes Ares obligatoires conservées ; privilégier le burst Aghasura pendant la fenêtre sans fuel de Legion."],
274+ ["Build GP with cheaper multi-hit Arts before committing the high-fuel cannon.", "Générer les GP avec des Arts multi-coups moins coûteux avant le canon à fort coût en fuel."],
275+ ],
276+ evidence: { id: "ares-90-kit", label: "Xeno Series Wiki — Ares 90", url: "https://www.xenoserieswiki.org/wiki/Ares_90", tier: 2, edition: "both" },
277+ },
278+];
new file mode 100644
@@ -0,0 +1,278 @@
1+import type {
2+ ContextualRoleVector,
3+ EvidenceRef,
4+ LocalizedText,
5+ StrategyKey,
6+} from "@/src/lib/types";
7+import type { SkellWeaponPlan } from "@/src/data/skellWeapons";
8+
9+export type StrategyArchetype = "tank" | "damage" | "support" | "hybrid";
10+
11+export const strategyArchetype: Record<StrategyKey, StrategyArchetype> = {
12+ duelist: "damage",
13+ bastion: "tank",
14+ jaguar: "damage",
15+ astral: "damage",
16+ mastermind: "support",
17+ galactic: "hybrid",
18+};
19+
20+/**
21+ * Provisional, deliberately low-impact priors distilled from progression-aware
22+ * DE build guides. Mechanics and role fit always carry more score weight.
23+ */
24+export const companionCommunityPrior: Readonly<Record<string, number>> = {
25+ Irina: 1,
26+ Elma: 0.94,
27+ "H.B.": 0.92,
28+ Nagi: 0.88,
29+ Mia: 0.82,
30+ Celica: 0.78,
31+ Lin: 0.76,
32+ Alexa: 0.72,
33+ Yelv: 0.72,
34+ L: 0.68,
35+ Lao: 0.66,
36+ Doug: 0.64,
37+ Liesel: 0.63,
38+ Hope: 0.62,
39+ Murderess: 0.61,
40+ Gwin: 0.6,
41+ Phog: 0.59,
42+ Neilnail: 0.58,
43+ "Ga Jiarg": 0.57,
44+ Frye: 0.56,
45+ Bozé: 0.55,
46+ Al: 0.54,
47+};
48+
49+export const evidenceCatalog = {
50+ battleArts: {
51+ id: "battle-arts",
52+ label: "Xeno Series Wiki — Battle Art (XCX)",
53+ url: "https://www.xenoserieswiki.org/wiki/Battle_Art_(XCX)",
54+ tier: 2,
55+ edition: "both",
56+ },
57+ skills: {
58+ id: "skills",
59+ label: "Xeno Series Wiki — Skill (XCX)",
60+ url: "https://www.xenoserieswiki.org/wiki/Skill_(XCX)",
61+ tier: 2,
62+ edition: "both",
63+ },
64+ cross: {
65+ id: "cross-gameplay",
66+ label: "Xeno Series Wiki — Cross/Gameplay",
67+ url: "https://www.xenoserieswiki.org/wiki/Cross/Gameplay",
68+ tier: 2,
69+ edition: "both",
70+ },
71+ overdrive: {
72+ id: "overdrive",
73+ label: "Xeno Series Wiki — Overdrive",
74+ url: "https://www.xenoserieswiki.org/wiki/Overdrive_(XCX)",
75+ tier: 2,
76+ edition: "both",
77+ },
78+ skell: {
79+ id: "skell",
80+ label: "Xeno Series Wiki — Skell",
81+ url: "https://www.xenoserieswiki.org/wiki/Skell",
82+ tier: 2,
83+ edition: "both",
84+ },
85+ skellWeapons: {
86+ id: "skell-weapons",
87+ label: "Xeno Series Wiki — Skell weapon",
88+ url: "https://www.xenoserieswiki.org/wiki/Skell_weapon",
89+ tier: 2,
90+ edition: "both",
91+ },
92+ communityBuilds: {
93+ id: "community-builds",
94+ label: "Nintendo Life — DE character builds",
95+ url: "https://www.nintendolife.com/guides/xenoblade-chronicles-x-best-combat-builds-for-all-characters",
96+ tier: 3,
97+ edition: "DE",
98+ },
99+ communityTeams: {
100+ id: "community-teams",
101+ label: "Game8 — DE companion and progression guidance",
102+ url: "https://game8.co/games/Xenoblade-Chronicles-X/archives/506277",
103+ tier: 3,
104+ edition: "DE",
105+ },
106+} as const satisfies Record<string, EvidenceRef>;
107+
108+export const skillRoleSignals: Readonly<
109+ Record<string, Partial<ContextualRoleVector>>
110+> = {
111+ Synchrony: { skell: 3 },
112+ "Stellar Entry": { skell: 1.4 },
113+ "Grand Procession": { support: 1, tp: 2 },
114+ "High Tension": { tp: 1.4 },
115+ "TP Overdrive": { tp: 1.6 },
116+ "Trigger Happy": { tp: 1 },
117+ "Fast Forward": { tp: 1, support: 0.4 },
118+ "Healing Touch": { sustain: 1.4, support: 0.8 },
119+ "King's Decree": { control: 1, support: 0.4 },
120+ "Tactical Analyst": { control: 0.8, support: 0.6 },
121+ "Steel Flesh": { tank: 0.8 },
122+ "Fortified Flesh": { tank: 0.8 },
123+ "Mighty Muscle": { damage: 0.8 },
124+ "Aura Assault": { damage: 0.8 },
125+ "Electric Boost": { damage: 0.6 },
126+ "Conflagrant Edge": { damage: 0.8 },
127+};
128+
129+export interface FrameArchetype {
130+ key: string;
131+ label: LocalizedText;
132+ reason: LocalizedText;
133+ overdrive: string;
134+ fuelGpPlan: LocalizedText;
135+}
136+
137+export const frameArchetypes: readonly FrameArchetype[] = [
138+ {
139+ key: "Amdusias",
140+ label: ["Melee / appendage striker", "Frappeur mêlée / appendices"],
141+ reason: [
142+ "Melee-focused frame and Overdrive profile; prioritize high-force melee and GP tools.",
143+ "Frame et Overdrive orientés mêlée ; privilégier force mêlée et génération de GP.",
144+ ],
145+ overdrive: "Kurenai",
146+ fuelGpPlan: ["Build GP with efficient multi-hit Arts, then use Kurenai for melee fuel absorption.", "Générer les GP avec des Arts multi-coups efficients, puis utiliser Kurenai pour absorber du fuel en mêlée."],
147+ },
148+ {
149+ key: "Lailah",
150+ label: ["Sustain tank", "Tank de sustain"],
151+ reason: [
152+ "Defensive sustain profile; prioritize shields, control, GP, and efficient weapons.",
153+ "Profil défensif durable ; privilégier boucliers, contrôle, GP et armes efficientes.",
154+ ],
155+ overdrive: "Nagi",
156+ fuelGpPlan: ["Reach 3,000 GP with low-cost control Arts; Nagi removes fuel use and sustains long fights.", "Atteindre 3 000 GP avec des Arts de contrôle peu coûteux ; Nagi supprime le coût en fuel et soutient les combats longs."],
157+ },
158+ {
159+ key: "Mastema",
160+ label: ["Beam artillery", "Artillerie laser"],
161+ reason: [
162+ "Ranged/beam profile; prioritize ranged force, beam damage, and appendage utility.",
163+ "Profil distance/laser ; privilégier force à distance, dégâts laser et appendices.",
164+ ],
165+ overdrive: "Gekitetsu",
166+ fuelGpPlan: ["Generate GP before committing high-fuel artillery; Gekitetsu removes fuel use during the burst window.", "Générer les GP avant l'artillerie coûteuse ; Gekitetsu supprime le coût en fuel pendant la fenêtre de burst."],
167+ },
168+ {
169+ key: "Inferno",
170+ label: ["Thermal artillery", "Artillerie thermique"],
171+ reason: [
172+ "Ranged/thermal profile; prioritize thermal AoE, ranged force, and fuel economy.",
173+ "Profil distance/thermique ; privilégier AoE thermique, force à distance et fuel.",
174+ ],
175+ overdrive: "Hibana",
176+ fuelGpPlan: ["Use efficient thermal attacks to reach Overdrive before spending fuel on large AoE weapons.", "Utiliser des attaques thermiques efficientes pour atteindre l'Overdrive avant les grandes armes AoE coûteuses."],
177+ },
178+ {
179+ key: "Hraesvelg",
180+ label: ["Evasion / cooldown striker", "Frappeur évasion / cooldown"],
181+ reason: [
182+ "Accuracy, evasion, and cooldown profile with fixed equipment; evaluate its built-in package as a whole.",
183+ "Profil précision, évasion et cooldown à équipement fixe ; évaluer son package intégré complet.",
184+ ],
185+ overdrive: "Jinrai",
186+ fuelGpPlan: ["Cycle the fixed multi-hit kit for GP, then use Jinrai for fuel-free rapid cooldowns.", "Cycler le kit fixe multi-coups pour les GP, puis utiliser Jinrai pour des cooldowns rapides sans fuel."],
187+ },
188+ {
189+ key: "Formula",
190+ label: ["Agile generalist", "Généraliste agile"],
191+ reason: [
192+ "Agile frame profile; prioritize accurate, fast-cycling weapons and evasion support.",
193+ "Profil agile ; privilégier armes précises, rapides et soutien d'évasion.",
194+ ],
195+ overdrive: "Jinrai",
196+ fuelGpPlan: ["Favor low-cooldown multi-hit Arts to reach Jinrai and maintain fuel-free pressure.", "Privilégier les Arts multi-coups à faible cooldown pour atteindre Jinrai et maintenir la pression sans fuel."],
197+ },
198+ {
199+ key: "Verus",
200+ label: ["Physical melee duelist", "Duelliste mêlée physique"],
201+ reason: [
202+ "Physical melee and reflect profile; prioritize melee burst and defensive utility.",
203+ "Profil mêlée physique et reflect ; privilégier burst mêlée et utilité défensive.",
204+ ],
205+ overdrive: "Hagane",
206+ fuelGpPlan: ["Build GP with low-cost melee Arts, then exploit Hagane's melee boost and physical reflect.", "Générer les GP avec des Arts mêlée peu coûteux, puis exploiter le bonus mêlée et le reflect physique de Hagane."],
207+ },
208+ {
209+ key: "Urban",
210+ label: ["Accessible generalist", "Généraliste accessible"],
211+ reason: [
212+ "License/progression frame; prioritize accessible balanced weapons over specialization.",
213+ "Frame de licence/progression ; privilégier armes équilibrées et accessibles.",
214+ ],
215+ overdrive: "Oboro",
216+ fuelGpPlan: ["Use accessible low-cost weapons to build GP; save large fuel spends for Oboro's zero-fuel window.", "Utiliser des armes accessibles peu coûteuses pour les GP ; réserver les fortes dépenses à la fenêtre sans fuel d'Oboro."],
217+ },
218+ {
219+ key: "Ares",
220+ label: ["Fixed-kit burst", "Burst à kit fixe"],
221+ reason: [
222+ "Fixed burst package; build GP before using its high-fuel Ether weapons inside Legion.",
223+ "Package de burst fixe ; générer les GP avant les armes Éther coûteuses en fuel pendant Legion.",
224+ ],
225+ overdrive: "Legion",
226+ fuelGpPlan: ["Build 3,000 GP with cheaper multi-hit Arts, enter Legion, then fire Aghasura during zero fuel use.", "Générer 3 000 GP avec des Arts multi-coups moins coûteux, activer Legion, puis utiliser Aghasura sans coût de fuel."],
227+ },
228+];
229+
230+export interface FixedSkellKit {
231+ matches: readonly string[];
232+ overdrive: string;
233+ weapons: readonly SkellWeaponPlan[];
234+ augments: readonly string[];
235+ notes: readonly LocalizedText[];
236+ evidence: EvidenceRef;
237+}
238+
239+export const fixedSkellKits: readonly FixedSkellKit[] = [
240+ {
241+ matches: ["Hraesvelg"],
242+ overdrive: "Jinrai",
243+ weapons: [
244+ { name: "Blade-Mode", slot: "sidearm", attribute: "beam", acquisition: "Fixed equipment" },
245+ { name: "Gun-Mode", slot: "sidearm", attribute: "beam", acquisition: "Fixed equipment" },
246+ { name: "G-Slash", slot: "back", attribute: "gravity", acquisition: "Fixed equipment" },
247+ { name: "S-Nova", slot: "shoulder", attribute: "gravity", acquisition: "Fixed equipment" },
248+ { name: "Boost", slot: "shoulder", attribute: "physical", acquisition: "Fixed equipment" },
249+ { name: "Gunform", slot: "arm", attribute: "physical", acquisition: "Fixed equipment" },
250+ { name: "Build", slot: "arm", attribute: "physical", acquisition: "Fixed equipment" },
251+ { name: "Claw-Whip", slot: "spare", attribute: "beam", acquisition: "Fixed equipment" },
252+ ],
253+ augments: ["Custom.WP-ATK", "Arts.GP", "SpecUp.EVA"],
254+ notes: [
255+ ["Mandatory weapons preserved; normal Skell weapons cannot replace this package.", "Armes obligatoires conservées ; les armes Skell normales ne peuvent pas remplacer ce package."],
256+ ["Hunter and W-Blade remain quest-gated and are not assumed in the safe package.", "Hunter et W-Blade restent liés à une quête et ne sont pas supposés dans le package sûr."],
257+ ],
258+ evidence: { id: "hraesvelg-kit", label: "Game8 — Hraesvelg frames", url: "https://game8.co/games/Xenoblade-Chronicles-X/archives/510136", tier: 3, edition: "DE" },
259+ },
260+ {
261+ matches: ["Ares 90", "Ares 70", "Ares Prime"],
262+ overdrive: "Legion",
263+ weapons: [
264+ { name: "M-BLASTER", slot: "sidearm", attribute: "ether", acquisition: "Fixed equipment" },
265+ { name: "M-BLADE", slot: "sidearm", attribute: "ether", acquisition: "Fixed equipment" },
266+ { name: "AGHASURA Cannon", slot: "shoulder", attribute: "ether", acquisition: "Fixed equipment" },
267+ { name: "AGNI Gatling", slot: "back", attribute: "ether", acquisition: "Fixed equipment" },
268+ { name: "VAJRA Flail", slot: "shoulder", attribute: "physical", acquisition: "Fixed equipment" },
269+ { name: "AVATARA Missile", slot: "back", attribute: "thermal", acquisition: "Fixed equipment" },
270+ ],
271+ augments: ["AttributeDmg.ETHER", "Arts.GP", "Custom.WP-ATK"],
272+ notes: [
273+ ["Mandatory Ares weapons preserved; prioritize Aghasura burst inside Legion's zero-fuel window.", "Armes Ares obligatoires conservées ; privilégier le burst Aghasura pendant la fenêtre sans fuel de Legion."],
274+ ["Build GP with cheaper multi-hit Arts before committing the high-fuel cannon.", "Générer les GP avec des Arts multi-coups moins coûteux avant le canon à fort coût en fuel."],
275+ ],
276+ evidence: { id: "ares-90-kit", label: "Xeno Series Wiki — Ares 90", url: "https://www.xenoserieswiki.org/wiki/Ares_90", tier: 2, edition: "both" },
277+ },
278+];
modified src/data/i18n.ts +83 -6
@@ -15,6 +15,11 @@ export interface Copy {
1515 rank: string;
1616 hierarchy: string;
1717 weapons: string;
18+ weaponLoadout: string;
19+ rangedWeapon: string;
20+ meleeWeapon: string;
21+ autoWeapon: string;
22+ weaponOverrideHint: string;
1823 allClassRanks: string;
1924 builderHeading: string;
2025 squad: string;
@@ -24,8 +29,28 @@ export interface Copy {
2429 skellProgression: string;
2530 skellLicense: string;
2631 postgameFrames: string;
32+ combatContext: string;
33+ combatMode: string;
34+ generalMode: string;
35+ skellMode: string;
36+ specialUnlocks: string;
37+ specialUnlockHint: string;
38+ primaryParty: string;
39+ offensiveParty: string;
40+ safeParty: string;
41+ confidence: string;
42+ recommendationReasons: string;
43+ scoring: string;
44+ infeasible: string;
45+ violations: string;
46+ relaxations: string;
47+ fallback: string;
48+ specialArt: string;
2749 viewResults: string;
50+ resultsEyebrowGeneral: string;
51+ resultsEyebrowSkell: string;
2852 resultsHeading: string;
53+ resultsHeadingSkell: string;
2954 activeRoute: string;
3055 teamMode: string;
3156 manualPick: string;
@@ -64,7 +89,7 @@ export const copy: Record<Lang, Copy> = {
6489 status: "SYSTEM READY",
6590 eyebrow: "BLADE // TACTICAL SYSTEM 07",
6691 title: "Team Architect",
67- subtitle: "Create a sourced, progression-safe general-purpose recommendation for Mira—not a universal best build.",
92+ subtitle: "Create a progression-safe general-purpose recommendation for Mira—not a universal best build.",
6893 avatarLevel: "Cross level",
6994 levelShort: "LVL",
7095 personalRank: "Personal class rank",
@@ -74,6 +99,11 @@ export const copy: Record<Lang, Copy> = {
7499 rank: "Active class rank",
75100 hierarchy: "Class route",
76101 weapons: "Native weapons",
102+ weaponLoadout: "Cross weapon loadout",
103+ rangedWeapon: "Ranged weapon",
104+ meleeWeapon: "Melee weapon",
105+ autoWeapon: "AUTO RECOMMEND",
106+ weaponOverrideHint: "Native weapons are immediately available. Rank 10 final-class mastery adds that class's weapons for cross-class mixing.",
77107 allClassRanks: "All class ranks",
78108 builderHeading: "Configure your deployment",
79109 squad: "Squad roster",
@@ -83,8 +113,28 @@ export const copy: Record<Lang, Copy> = {
83113 skellProgression: "Skell progression",
84114 skellLicense: "Has Skell License",
85115 postgameFrames: "Postgame frame schematics",
116+ combatContext: "Combat context",
117+ combatMode: "Mode",
118+ generalMode: "General",
119+ skellMode: "Skell",
120+ specialUnlocks: "Affinity and quest Signature Arts unlocked",
121+ specialUnlockHint: "Keeps rank gates; every special Art includes a safe fallback.",
122+ primaryParty: "Primary",
123+ offensiveParty: "Offensive",
124+ safeParty: "Safe / accessible",
125+ confidence: "Confidence",
126+ recommendationReasons: "Why this party",
127+ scoring: "Context score",
128+ infeasible: "No valid recommendation",
129+ violations: "Constraints blocking this request",
130+ relaxations: "Suggested relaxations",
131+ fallback: "Fallback",
132+ specialArt: "Signature Art",
86133 viewResults: "View live recommendation",
134+ resultsEyebrowGeneral: "TACTICAL OUTPUT",
135+ resultsEyebrowSkell: "SKELL TACTICAL OUTPUT",
87136 resultsHeading: "All-rounder recommendation",
137+ resultsHeadingSkell: "Skell combat recommendation",
88138 activeRoute: "ACTIVE ROUTE",
89139 teamMode: "TEAM MODE",
90140 manualPick: "MANUAL",
@@ -111,8 +161,8 @@ export const copy: Record<Lang, Copy> = {
111161 skellNoLicense: "No frame is recommended without the Skell License.",
112162 skellLowLevel: "No frame is recommended before pilot level 20.",
113163 methodTitle: "Scope and method",
114- methodBody: "This is a sourced, progression-safe general-purpose recommendation, not a universal best build. Only learned Arts and Skills are checked against the entered class or personal ranks; the displayed weapon compatibility is also enforced. Encounter demands, gear traits, owned inventory, affinity and signature Arts, and player execution can change the best build.",
115- footerTagline: "Unofficial tactical companion",
164+ methodBody: "Contextual scores enforce level, rank, weapon, and slot constraints before comparing role balance, offense, safety, progression, Skell aptitude, and a moderate community prior. Results are explainable progression-safe recommendations—not universal clear guarantees. Signature Arts appear only when enabled and always show a regular-Art fallback; equipment advice names AMs, stats, traits, and augment families without inventing owned gear.",
165+ footerTagline: "© 2026 BLADE Team Architect. Unofficial fan project, not affiliated with, endorsed by, or sponsored by Nintendo or MONOLITHSOFT. Xenoblade Chronicles and related names, characters, and assets belong to their respective rights holders.",
116166 rankDown: "Decrease rank",
117167 rankUp: "Increase rank",
118168 classRanksHint: "Selecting a class masters its parent route and resets other branches. Lowering a rank below 10 resets its trained child classes to 0.",
@@ -121,7 +171,7 @@ export const copy: Record<Lang, Copy> = {
121171 status: "SYSTÈME PRÊT",
122172 eyebrow: "BLADE // SYSTÈME TACTIQUE 07",
123173 title: "Architecte d’équipe",
124- subtitle: "Créez une recommandation généraliste sourcée et sûre pour votre progression sur Mira — pas un build universellement optimal.",
174+ subtitle: "Créez une recommandation généraliste sûre pour votre progression sur Mira — pas un build universellement optimal.",
125175 avatarLevel: "Niveau de Cross",
126176 levelShort: "NIV",
127177 personalRank: "Rang de classe personnel",
@@ -131,6 +181,11 @@ export const copy: Record<Lang, Copy> = {
131181 rank: "Rang de la classe active",
132182 hierarchy: "Filière de classe",
133183 weapons: "Armes natives",
184+ weaponLoadout: "Armes de Cross",
185+ rangedWeapon: "Arme à distance",
186+ meleeWeapon: "Arme de mêlée",
187+ autoWeapon: "RECOMMANDATION AUTO",
188+ weaponOverrideHint: "Les armes natives sont disponibles immédiatement. Maîtriser une classe finale au rang 10 permet d'utiliser ses armes avec les autres classes.",
134189 allClassRanks: "Rangs de toutes les classes",
135190 builderHeading: "Configurez votre déploiement",
136191 squad: "Composition de l’équipe",
@@ -140,8 +195,28 @@ export const copy: Record<Lang, Copy> = {
140195 skellProgression: "Progression Skell",
141196 skellLicense: "Licence Skell obtenue",
142197 postgameFrames: "Schémas de châssis postgame",
198+ combatContext: "Contexte de combat",
199+ combatMode: "Mode",
200+ generalMode: "Général",
201+ skellMode: "Skell",
202+ specialUnlocks: "Arts signature d'affinité et de quête débloqués",
203+ specialUnlockHint: "Respecte les rangs ; chaque Art spécial inclut une alternative sûre.",
204+ primaryParty: "Principale",
205+ offensiveParty: "Offensive",
206+ safeParty: "Sûre / accessible",
207+ confidence: "Confiance",
208+ recommendationReasons: "Pourquoi cette équipe",
209+ scoring: "Score contextuel",
210+ infeasible: "Aucune recommandation valide",
211+ violations: "Contraintes bloquant la requête",
212+ relaxations: "Ajustements suggérés",
213+ fallback: "Alternative sûre",
214+ specialArt: "Art signature",
143215 viewResults: "Voir la recommandation en direct",
216+ resultsEyebrowGeneral: "SORTIE TACTIQUE",
217+ resultsEyebrowSkell: "SORTIE TACTIQUE SKELL",
144218 resultsHeading: "Recommandation polyvalente",
219+ resultsHeadingSkell: "Recommandation de combat Skell",
145220 activeRoute: "FILIÈRE ACTIVE",
146221 teamMode: "MODE D’ÉQUIPE",
147222 manualPick: "MANUEL",
@@ -168,8 +243,8 @@ export const copy: Record<Lang, Copy> = {
168243 skellNoLicense: "Aucun châssis n’est recommandé sans la Licence Skell.",
169244 skellLowLevel: "Aucun châssis n’est recommandé avant le niveau de pilote 20.",
170245 methodTitle: "Portée et méthode",
171- 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.",
172- footerTagline: "Compagnon tactique non officiel",
246+ methodBody: "Les scores contextuels appliquent d'abord les contraintes de niveau, rang, armes et slots, puis comparent équilibre des rôles, offense, sécurité, progression, aptitude Skell et un a priori communautaire modéré. Ce sont des recommandations explicables et sûres pour la progression, pas des garanties universelles. Les Arts signature n'apparaissent que si activés et affichent toujours un Art régulier de remplacement ; les conseils d'équipement nomment AM, stats, traits et familles d'augments sans inventer l'inventaire possédé.",
247+ footerTagline: "© 2026 BLADE Team Architect. Projet de fans non officiel, sans affiliation, approbation ni parrainage de Nintendo ou MONOLITHSOFT. Xenoblade Chronicles ainsi que les noms, personnages et éléments associés appartiennent à leurs ayants droit respectifs.",
173248 rankDown: "Diminuer le rang",
174249 rankUp: "Augmenter le rang",
175250 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.",
@@ -187,6 +262,8 @@ const frenchSkellTerms: Readonly<Record<string, string>> = {
187262 "Complete Chapter 13 (Act 1)": "Terminer le chapitre 13 (acte 1)",
188263 "Craftable following Rapid Misfire": "Fabricable après « Rapid Misfire »",
189264 "Shop purchase / New LA craft": "Achat en boutique / fabrication à New LA",
265+ "Fixed equipment": "Équipement fixe",
266+ spare: "auxiliaire",
190267 sidearm: "arme latérale",
191268 back: "dos",
192269 shoulder: "épaule",
@@ -15,6 +15,11 @@ export interface Copy {
15 rank: string;15 rank: string;
16 hierarchy: string;16 hierarchy: string;
17 weapons: string;17 weapons: string;
18+ weaponLoadout: string;
19+ rangedWeapon: string;
20+ meleeWeapon: string;
21+ autoWeapon: string;
22+ weaponOverrideHint: string;
18 allClassRanks: string;23 allClassRanks: string;
19 builderHeading: string;24 builderHeading: string;
20 squad: string;25 squad: string;
@@ -24,8 +29,28 @@ export interface Copy {
24 skellProgression: string;29 skellProgression: string;
25 skellLicense: string;30 skellLicense: string;
26 postgameFrames: string;31 postgameFrames: string;
32+ combatContext: string;
33+ combatMode: string;
34+ generalMode: string;
35+ skellMode: string;
36+ specialUnlocks: string;
37+ specialUnlockHint: string;
38+ primaryParty: string;
39+ offensiveParty: string;
40+ safeParty: string;
41+ confidence: string;
42+ recommendationReasons: string;
43+ scoring: string;
44+ infeasible: string;
45+ violations: string;
46+ relaxations: string;
47+ fallback: string;
48+ specialArt: string;
27 viewResults: string;49 viewResults: string;
50+ resultsEyebrowGeneral: string;
51+ resultsEyebrowSkell: string;
28 resultsHeading: string;52 resultsHeading: string;
53+ resultsHeadingSkell: string;
29 activeRoute: string;54 activeRoute: string;
30 teamMode: string;55 teamMode: string;
31 manualPick: string;56 manualPick: string;
@@ -64,7 +89,7 @@ export const copy: Record<Lang, Copy> = {
64 status: "SYSTEM READY",89 status: "SYSTEM READY",
65 eyebrow: "BLADE // TACTICAL SYSTEM 07",90 eyebrow: "BLADE // TACTICAL SYSTEM 07",
66 title: "Team Architect",91 title: "Team Architect",
67- subtitle: "Create a sourced, progression-safe general-purpose recommendation for Mira—not a universal best build.",92+ subtitle: "Create a progression-safe general-purpose recommendation for Mira—not a universal best build.",
68 avatarLevel: "Cross level",93 avatarLevel: "Cross level",
69 levelShort: "LVL",94 levelShort: "LVL",
70 personalRank: "Personal class rank",95 personalRank: "Personal class rank",
@@ -74,6 +99,11 @@ export const copy: Record<Lang, Copy> = {
74 rank: "Active class rank",99 rank: "Active class rank",
75 hierarchy: "Class route",100 hierarchy: "Class route",
76 weapons: "Native weapons",101 weapons: "Native weapons",
102+ weaponLoadout: "Cross weapon loadout",
103+ rangedWeapon: "Ranged weapon",
104+ meleeWeapon: "Melee weapon",
105+ autoWeapon: "AUTO RECOMMEND",
106+ weaponOverrideHint: "Native weapons are immediately available. Rank 10 final-class mastery adds that class's weapons for cross-class mixing.",
77 allClassRanks: "All class ranks",107 allClassRanks: "All class ranks",
78 builderHeading: "Configure your deployment",108 builderHeading: "Configure your deployment",
79 squad: "Squad roster",109 squad: "Squad roster",
@@ -83,8 +113,28 @@ export const copy: Record<Lang, Copy> = {
83 skellProgression: "Skell progression",113 skellProgression: "Skell progression",
84 skellLicense: "Has Skell License",114 skellLicense: "Has Skell License",
85 postgameFrames: "Postgame frame schematics",115 postgameFrames: "Postgame frame schematics",
116+ combatContext: "Combat context",
117+ combatMode: "Mode",
118+ generalMode: "General",
119+ skellMode: "Skell",
120+ specialUnlocks: "Affinity and quest Signature Arts unlocked",
121+ specialUnlockHint: "Keeps rank gates; every special Art includes a safe fallback.",
122+ primaryParty: "Primary",
123+ offensiveParty: "Offensive",
124+ safeParty: "Safe / accessible",
125+ confidence: "Confidence",
126+ recommendationReasons: "Why this party",
127+ scoring: "Context score",
128+ infeasible: "No valid recommendation",
129+ violations: "Constraints blocking this request",
130+ relaxations: "Suggested relaxations",
131+ fallback: "Fallback",
132+ specialArt: "Signature Art",
86 viewResults: "View live recommendation",133 viewResults: "View live recommendation",
134+ resultsEyebrowGeneral: "TACTICAL OUTPUT",
135+ resultsEyebrowSkell: "SKELL TACTICAL OUTPUT",
87 resultsHeading: "All-rounder recommendation",136 resultsHeading: "All-rounder recommendation",
137+ resultsHeadingSkell: "Skell combat recommendation",
88 activeRoute: "ACTIVE ROUTE",138 activeRoute: "ACTIVE ROUTE",
89 teamMode: "TEAM MODE",139 teamMode: "TEAM MODE",
90 manualPick: "MANUAL",140 manualPick: "MANUAL",
@@ -111,8 +161,8 @@ export const copy: Record<Lang, Copy> = {
111 skellNoLicense: "No frame is recommended without the Skell License.",161 skellNoLicense: "No frame is recommended without the Skell License.",
112 skellLowLevel: "No frame is recommended before pilot level 20.",162 skellLowLevel: "No frame is recommended before pilot level 20.",
113 methodTitle: "Scope and method",163 methodTitle: "Scope and method",
114- methodBody: "This is a sourced, progression-safe general-purpose recommendation, not a universal best build. Only learned Arts and Skills are checked against the entered class or personal ranks; the displayed weapon compatibility is also enforced. Encounter demands, gear traits, owned inventory, affinity and signature Arts, and player execution can change the best build.",164+ methodBody: "Contextual scores enforce level, rank, weapon, and slot constraints before comparing role balance, offense, safety, progression, Skell aptitude, and a moderate community prior. Results are explainable progression-safe recommendations—not universal clear guarantees. Signature Arts appear only when enabled and always show a regular-Art fallback; equipment advice names AMs, stats, traits, and augment families without inventing owned gear.",
115- footerTagline: "Unofficial tactical companion",165+ footerTagline: "© 2026 BLADE Team Architect. Unofficial fan project, not affiliated with, endorsed by, or sponsored by Nintendo or MONOLITHSOFT. Xenoblade Chronicles and related names, characters, and assets belong to their respective rights holders.",
116 rankDown: "Decrease rank",166 rankDown: "Decrease rank",
117 rankUp: "Increase rank",167 rankUp: "Increase rank",
118 classRanksHint: "Selecting a class masters its parent route and resets other branches. Lowering a rank below 10 resets its trained child classes to 0.",168 classRanksHint: "Selecting a class masters its parent route and resets other branches. Lowering a rank below 10 resets its trained child classes to 0.",
@@ -121,7 +171,7 @@ export const copy: Record<Lang, Copy> = {
121 status: "SYSTÈME PRÊT",171 status: "SYSTÈME PRÊT",
122 eyebrow: "BLADE // SYSTÈME TACTIQUE 07",172 eyebrow: "BLADE // SYSTÈME TACTIQUE 07",
123 title: "Architecte d’équipe",173 title: "Architecte d’équipe",
124- subtitle: "Créez une recommandation généraliste sourcée et sûre pour votre progression sur Mira — pas un build universellement optimal.",174+ subtitle: "Créez une recommandation généraliste sûre pour votre progression sur Mira — pas un build universellement optimal.",
125 avatarLevel: "Niveau de Cross",175 avatarLevel: "Niveau de Cross",
126 levelShort: "NIV",176 levelShort: "NIV",
127 personalRank: "Rang de classe personnel",177 personalRank: "Rang de classe personnel",
@@ -131,6 +181,11 @@ export const copy: Record<Lang, Copy> = {
131 rank: "Rang de la classe active",181 rank: "Rang de la classe active",
132 hierarchy: "Filière de classe",182 hierarchy: "Filière de classe",
133 weapons: "Armes natives",183 weapons: "Armes natives",
184+ weaponLoadout: "Armes de Cross",
185+ rangedWeapon: "Arme à distance",
186+ meleeWeapon: "Arme de mêlée",
187+ autoWeapon: "RECOMMANDATION AUTO",
188+ weaponOverrideHint: "Les armes natives sont disponibles immédiatement. Maîtriser une classe finale au rang 10 permet d'utiliser ses armes avec les autres classes.",
134 allClassRanks: "Rangs de toutes les classes",189 allClassRanks: "Rangs de toutes les classes",
135 builderHeading: "Configurez votre déploiement",190 builderHeading: "Configurez votre déploiement",
136 squad: "Composition de l’équipe",191 squad: "Composition de l’équipe",
@@ -140,8 +195,28 @@ export const copy: Record<Lang, Copy> = {
140 skellProgression: "Progression Skell",195 skellProgression: "Progression Skell",
141 skellLicense: "Licence Skell obtenue",196 skellLicense: "Licence Skell obtenue",
142 postgameFrames: "Schémas de châssis postgame",197 postgameFrames: "Schémas de châssis postgame",
198+ combatContext: "Contexte de combat",
199+ combatMode: "Mode",
200+ generalMode: "Général",
201+ skellMode: "Skell",
202+ specialUnlocks: "Arts signature d'affinité et de quête débloqués",
203+ specialUnlockHint: "Respecte les rangs ; chaque Art spécial inclut une alternative sûre.",
204+ primaryParty: "Principale",
205+ offensiveParty: "Offensive",
206+ safeParty: "Sûre / accessible",
207+ confidence: "Confiance",
208+ recommendationReasons: "Pourquoi cette équipe",
209+ scoring: "Score contextuel",
210+ infeasible: "Aucune recommandation valide",
211+ violations: "Contraintes bloquant la requête",
212+ relaxations: "Ajustements suggérés",
213+ fallback: "Alternative sûre",
214+ specialArt: "Art signature",
143 viewResults: "Voir la recommandation en direct",215 viewResults: "Voir la recommandation en direct",
216+ resultsEyebrowGeneral: "SORTIE TACTIQUE",
217+ resultsEyebrowSkell: "SORTIE TACTIQUE SKELL",
144 resultsHeading: "Recommandation polyvalente",218 resultsHeading: "Recommandation polyvalente",
219+ resultsHeadingSkell: "Recommandation de combat Skell",
145 activeRoute: "FILIÈRE ACTIVE",220 activeRoute: "FILIÈRE ACTIVE",
146 teamMode: "MODE D’ÉQUIPE",221 teamMode: "MODE D’ÉQUIPE",
147 manualPick: "MANUEL",222 manualPick: "MANUEL",
@@ -168,8 +243,8 @@ export const copy: Record<Lang, Copy> = {
168 skellNoLicense: "Aucun châssis n’est recommandé sans la Licence Skell.",243 skellNoLicense: "Aucun châssis n’est recommandé sans la Licence Skell.",
169 skellLowLevel: "Aucun châssis n’est recommandé avant le niveau de pilote 20.",244 skellLowLevel: "Aucun châssis n’est recommandé avant le niveau de pilote 20.",
170 methodTitle: "Portée et méthode",245 methodTitle: "Portée et méthode",
171- 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.",246+ methodBody: "Les scores contextuels appliquent d'abord les contraintes de niveau, rang, armes et slots, puis comparent équilibre des rôles, offense, sécurité, progression, aptitude Skell et un a priori communautaire modéré. Ce sont des recommandations explicables et sûres pour la progression, pas des garanties universelles. Les Arts signature n'apparaissent que si activés et affichent toujours un Art régulier de remplacement ; les conseils d'équipement nomment AM, stats, traits et familles d'augments sans inventer l'inventaire possédé.",
172- footerTagline: "Compagnon tactique non officiel",247+ footerTagline: "© 2026 BLADE Team Architect. Projet de fans non officiel, sans affiliation, approbation ni parrainage de Nintendo ou MONOLITHSOFT. Xenoblade Chronicles ainsi que les noms, personnages et éléments associés appartiennent à leurs ayants droit respectifs.",
173 rankDown: "Diminuer le rang",248 rankDown: "Diminuer le rang",
174 rankUp: "Augmenter le rang",249 rankUp: "Augmenter le rang",
175 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.",250 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.",
@@ -187,6 +262,8 @@ const frenchSkellTerms: Readonly<Record<string, string>> = {
187 "Complete Chapter 13 (Act 1)": "Terminer le chapitre 13 (acte 1)",262 "Complete Chapter 13 (Act 1)": "Terminer le chapitre 13 (acte 1)",
188 "Craftable following Rapid Misfire": "Fabricable après « Rapid Misfire »",263 "Craftable following Rapid Misfire": "Fabricable après « Rapid Misfire »",
189 "Shop purchase / New LA craft": "Achat en boutique / fabrication à New LA",264 "Shop purchase / New LA craft": "Achat en boutique / fabrication à New LA",
265+ "Fixed equipment": "Équipement fixe",
266+ spare: "auxiliaire",
190 sidearm: "arme latérale",267 sidearm: "arme latérale",
191 back: "dos",268 back: "dos",
192 shoulder: "épaule",269 shoulder: "épaule",
modified src/data/skellWeapons.ts +1 -1
@@ -2,7 +2,7 @@ import type { StrategyKey } from "@/src/lib/types";
22
33 export interface SkellWeaponPlan {
44 name: string;
5- slot: "sidearm" | "back" | "shoulder" | "arm";
5+ slot: "sidearm" | "back" | "shoulder" | "arm" | "spare";
66 attribute: string;
77 acquisition: string;
88 }
@@ -2,7 +2,7 @@ import type { StrategyKey } from "@/src/lib/types";
2 2
3 export interface SkellWeaponPlan {3 export interface SkellWeaponPlan {
4 name: string;4 name: string;
5- slot: "sidearm" | "back" | "shoulder" | "arm";5+ slot: "sidearm" | "back" | "shoulder" | "arm" | "spare";
6 attribute: string;6 attribute: string;
7 acquisition: string;7 acquisition: string;
8 }8 }
added src/lib/contextualEngine.test.ts +268 -0
new file mode 100644
@@ -0,0 +1,268 @@
1+import assert from "node:assert/strict";
2+import test from "node:test";
3+import {
4+ evaluateCompanionContext,
5+ planContextualAvatar,
6+ recommendContextualSkellLoadout,
7+ recommendContextualTeams,
8+} from "@/src/lib/contextualEngine";
9+import { classRanksForActiveClass, editClassRank } from "@/src/lib/engine";
10+import type {
11+ ClassRanks,
12+ ContextualRecommendationRequest,
13+} from "@/src/lib/types";
14+
15+function ranksFor(classId: string, rank = 10): ClassRanks {
16+ return editClassRank(classRanksForActiveClass(classId, {}), classId, rank);
17+}
18+
19+function request(
20+ overrides: Partial<ContextualRecommendationRequest> = {},
21+): ContextualRecommendationRequest {
22+ return {
23+ slots: ["", "", ""],
24+ activeClassId: "duelist",
25+ avatarLevel: 50,
26+ classRanks: ranksFor("duelist"),
27+ personalRanks: [20, 20, 20],
28+ combatMode: "general",
29+ hasSkellLicense: true,
30+ postgameFrames: false,
31+ assumeSpecialUnlocks: false,
32+ lang: "en",
33+ ...overrides,
34+ };
35+}
36+
37+test("contextual recommendations return one primary and two distinct alternatives", () => {
38+ const result = recommendContextualTeams(request());
39+
40+ assert.equal(result.kind, "success");
41+ if (result.kind !== "success") return;
42+
43+ assert.deepEqual(
44+ result.recommendations.map((recommendation) => recommendation.variant),
45+ ["primary", "offensive", "safe"],
46+ );
47+ assert.equal(result.recommendations.length, 3);
48+ assert.equal(
49+ new Set(
50+ result.recommendations.map((recommendation) =>
51+ [...recommendation.team.names].sort().join("|"),
52+ ),
53+ ).size,
54+ 3,
55+ );
56+ assert.ok(
57+ result.recommendations.every(
58+ (recommendation) => recommendation.roster.length === 4,
59+ ),
60+ );
61+ assert.ok(
62+ result.recommendations.every(
63+ (recommendation) => recommendation.reasons.length > 0,
64+ ),
65+ );
66+});
67+
68+test("manual companions remain fixed in every contextual alternative", () => {
69+ const result = recommendContextualTeams(request({ slots: ["Lin", "", ""] }));
70+
71+ assert.equal(result.kind, "success");
72+ if (result.kind !== "success") return;
73+
74+ for (const recommendation of result.recommendations) {
75+ assert.equal(recommendation.team.names[0], "Lin");
76+ assert.equal(recommendation.team.slots[0].provenance, "manual");
77+ }
78+});
79+
80+test("slot rank changes the companion's contextual progression and role profile", () => {
81+ const lowRank = evaluateCompanionContext("Irina", 50, 3, "general");
82+ const maxRank = evaluateCompanionContext("Irina", 50, 20, "general");
83+
84+ assert.ok(lowRank.progression < maxRank.progression);
85+ assert.ok(lowRank.roles.support < maxRank.roles.support);
86+ assert.equal(lowRank.personalRank, 3);
87+ assert.equal(maxRank.personalRank, 20);
88+});
89+
90+test("special unlock toggle adds compatible Signature Arts with safe fallbacks", () => {
91+ const classRanks = ranksFor("full-metal-jaguar");
92+ const safe = planContextualAvatar({
93+ activeClassId: "full-metal-jaguar",
94+ classRanks,
95+ level: 50,
96+ lang: "en",
97+ assumeSpecialUnlocks: false,
98+ });
99+ const expanded = planContextualAvatar({
100+ activeClassId: "full-metal-jaguar",
101+ classRanks,
102+ level: 50,
103+ lang: "en",
104+ assumeSpecialUnlocks: true,
105+ });
106+
107+ assert.equal(
108+ safe.arts.some((art) => art.specialUnlock),
109+ false,
110+ );
111+ const specialArts = expanded.arts.filter((art) => art.specialUnlock);
112+ assert.ok(specialArts.length > 0);
113+ assert.ok(specialArts.every((art) => art.fallback));
114+ assert.ok(
115+ expanded.arts.every(
116+ (art) =>
117+ art.weapon === expanded.weapons.ranged ||
118+ art.weapon === expanded.weapons.melee,
119+ ),
120+ );
121+});
122+
123+test("independent weapon overrides rebuild Cross and contextual recommendations", () => {
124+ const classRanks = editClassRank(
125+ ranksFor("galactic-knight", 1),
126+ "full-metal-jaguar",
127+ 10,
128+ );
129+ const automatic = planContextualAvatar({
130+ activeClassId: "galactic-knight",
131+ classRanks,
132+ level: 60,
133+ lang: "en",
134+ assumeSpecialUnlocks: false,
135+ });
136+ const mixed = planContextualAvatar({
137+ activeClassId: "galactic-knight",
138+ classRanks,
139+ level: 60,
140+ lang: "en",
141+ assumeSpecialUnlocks: false,
142+ weaponOverrides: { ranged: "Dual Guns", melee: "Photon Saber" },
143+ });
144+ assert.deepEqual(mixed.weapons, {
145+ ranged: "Dual Guns",
146+ melee: "Photon Saber",
147+ });
148+ assert.notDeepEqual(
149+ mixed.arts.map((art) => art.name),
150+ automatic.arts.map((art) => art.name),
151+ );
152+
153+ const result = recommendContextualTeams(request({
154+ activeClassId: "galactic-knight",
155+ avatarLevel: 60,
156+ classRanks,
157+ weaponOverrides: { ranged: "Dual Guns", melee: "Photon Saber" },
158+ }));
159+ assert.equal(result.kind, "success");
160+ if (result.kind !== "success") return;
161+ assert.deepEqual(result.recommendations[0].roster[0].weapons, mixed.weapons);
162+});
163+
164+test("General keeps Skell aptitude secondary and Skell mode equips relevant Skills", () => {
165+ const generalYelv = evaluateCompanionContext("Yelv", 50, 20, "general");
166+ const skellYelv = evaluateCompanionContext("Yelv", 50, 20, "skell");
167+ assert.ok(generalYelv.roles.skell > 0);
168+ assert.ok(generalYelv.roles.skell < skellYelv.roles.skell);
169+
170+ const result = recommendContextualTeams(request({ combatMode: "skell" }));
171+ assert.equal(result.kind, "success");
172+ if (result.kind !== "success") return;
173+
174+ const primary = result.recommendations[0];
175+ assert.ok(
176+ primary.team.names.some((name) => name === "Alexa" || name === "Yelv"),
177+ );
178+ assert.ok(primary.score.skell > 0);
179+ const skellPilots = primary.roster.filter((member) =>
180+ member.skills.some((skill) => skill.name === "Synchrony" || skill.name === "Stellar Entry"),
181+ );
182+ assert.ok(skellPilots.length > 0);
183+});
184+
185+test("General mode keeps Skell aptitude as a minor tie-breaker", () => {
186+ const withoutLicense = recommendContextualTeams(request({
187+ avatarLevel: 30,
188+ hasSkellLicense: false,
189+ }));
190+ const withLicense = recommendContextualTeams(request({
191+ avatarLevel: 30,
192+ hasSkellLicense: true,
193+ }));
194+ assert.equal(withoutLicense.kind, "success");
195+ assert.equal(withLicense.kind, "success");
196+ if (withoutLicense.kind !== "success" || withLicense.kind !== "success") return;
197+
198+ assert.deepEqual(
199+ withLicense.recommendations[0].team.names,
200+ withoutLicense.recommendations[0].team.names,
201+ );
202+ const licenseDelta = Math.abs(
203+ withLicense.recommendations[0].score.total
204+ - withoutLicense.recommendations[0].score.total,
205+ );
206+ assert.ok(licenseDelta >= 0.05);
207+ assert.ok(licenseDelta < 0.2);
208+});
209+
210+test("contextual Skill upgrade recommendations never exceed level 5", () => {
211+ const result = recommendContextualTeams(request({ avatarLevel: 99 }));
212+ assert.equal(result.kind, "success");
213+ if (result.kind !== "success") return;
214+
215+ for (const recommendation of result.recommendations) {
216+ for (const member of recommendation.roster) {
217+ for (const skill of member.skills) {
218+ assert.ok(skill.recommendedLevel >= 1 && skill.recommendedLevel <= 5);
219+ }
220+ }
221+ }
222+});
223+
224+test("an unavailable low-level party returns an explicit infeasible result", () => {
225+ const result = recommendContextualTeams(
226+ request({
227+ avatarLevel: 1,
228+ hasSkellLicense: false,
229+ personalRanks: [20, 20, 20],
230+ }),
231+ );
232+
233+ assert.equal(result.kind, "infeasible");
234+ if (result.kind !== "infeasible") return;
235+ assert.ok(result.violations.length > 0);
236+ assert.ok(result.relaxations.length > 0);
237+});
238+
239+test("contextual Skell loadouts explain frame and weapon-package synergy", () => {
240+ const loadout = recommendContextualSkellLoadout({
241+ strategy: "duelist",
242+ frame: "XS0730 Amdusias ST",
243+ frameLevel: 30,
244+ combatMode: "skell",
245+ lang: "en",
246+ });
247+
248+ assert.ok(loadout.weapons.length >= 3);
249+ assert.ok(loadout.augments.length >= 3);
250+ assert.ok(loadout.reason.length > 0);
251+ assert.ok(loadout.archetype.length > 0);
252+ assert.ok(loadout.evidence.length > 0);
253+});
254+ test("fixed-kit Skells preserve mandatory weapons", () => {
255+ const loadout = recommendContextualSkellLoadout({
256+ strategy: "jaguar",
257+ frame: "Hraesvelg Lord",
258+ frameLevel: 60,
259+ combatMode: "skell",
260+ lang: "en",
261+ });
262+
263+ assert.equal(loadout.fixedKit, true);
264+ assert.equal(loadout.overdrive, "Jinrai");
265+ assert.ok(loadout.weapons.some((weapon) => weapon.name === "G-Slash"));
266+ assert.ok(loadout.weapons.every((weapon) => weapon.acquisition === "Fixed equipment"));
267+ assert.ok(loadout.packageNotes.some((note) => note.includes("cannot replace")));
268+ });
new file mode 100644
@@ -0,0 +1,268 @@
1+import assert from "node:assert/strict";
2+import test from "node:test";
3+import {
4+ evaluateCompanionContext,
5+ planContextualAvatar,
6+ recommendContextualSkellLoadout,
7+ recommendContextualTeams,
8+} from "@/src/lib/contextualEngine";
9+import { classRanksForActiveClass, editClassRank } from "@/src/lib/engine";
10+import type {
11+ ClassRanks,
12+ ContextualRecommendationRequest,
13+} from "@/src/lib/types";
14+
15+function ranksFor(classId: string, rank = 10): ClassRanks {
16+ return editClassRank(classRanksForActiveClass(classId, {}), classId, rank);
17+}
18+
19+function request(
20+ overrides: Partial<ContextualRecommendationRequest> = {},
21+): ContextualRecommendationRequest {
22+ return {
23+ slots: ["", "", ""],
24+ activeClassId: "duelist",
25+ avatarLevel: 50,
26+ classRanks: ranksFor("duelist"),
27+ personalRanks: [20, 20, 20],
28+ combatMode: "general",
29+ hasSkellLicense: true,
30+ postgameFrames: false,
31+ assumeSpecialUnlocks: false,
32+ lang: "en",
33+ ...overrides,
34+ };
35+}
36+
37+test("contextual recommendations return one primary and two distinct alternatives", () => {
38+ const result = recommendContextualTeams(request());
39+
40+ assert.equal(result.kind, "success");
41+ if (result.kind !== "success") return;
42+
43+ assert.deepEqual(
44+ result.recommendations.map((recommendation) => recommendation.variant),
45+ ["primary", "offensive", "safe"],
46+ );
47+ assert.equal(result.recommendations.length, 3);
48+ assert.equal(
49+ new Set(
50+ result.recommendations.map((recommendation) =>
51+ [...recommendation.team.names].sort().join("|"),
52+ ),
53+ ).size,
54+ 3,
55+ );
56+ assert.ok(
57+ result.recommendations.every(
58+ (recommendation) => recommendation.roster.length === 4,
59+ ),
60+ );
61+ assert.ok(
62+ result.recommendations.every(
63+ (recommendation) => recommendation.reasons.length > 0,
64+ ),
65+ );
66+});
67+
68+test("manual companions remain fixed in every contextual alternative", () => {
69+ const result = recommendContextualTeams(request({ slots: ["Lin", "", ""] }));
70+
71+ assert.equal(result.kind, "success");
72+ if (result.kind !== "success") return;
73+
74+ for (const recommendation of result.recommendations) {
75+ assert.equal(recommendation.team.names[0], "Lin");
76+ assert.equal(recommendation.team.slots[0].provenance, "manual");
77+ }
78+});
79+
80+test("slot rank changes the companion's contextual progression and role profile", () => {
81+ const lowRank = evaluateCompanionContext("Irina", 50, 3, "general");
82+ const maxRank = evaluateCompanionContext("Irina", 50, 20, "general");
83+
84+ assert.ok(lowRank.progression < maxRank.progression);
85+ assert.ok(lowRank.roles.support < maxRank.roles.support);
86+ assert.equal(lowRank.personalRank, 3);
87+ assert.equal(maxRank.personalRank, 20);
88+});
89+
90+test("special unlock toggle adds compatible Signature Arts with safe fallbacks", () => {
91+ const classRanks = ranksFor("full-metal-jaguar");
92+ const safe = planContextualAvatar({
93+ activeClassId: "full-metal-jaguar",
94+ classRanks,
95+ level: 50,
96+ lang: "en",
97+ assumeSpecialUnlocks: false,
98+ });
99+ const expanded = planContextualAvatar({
100+ activeClassId: "full-metal-jaguar",
101+ classRanks,
102+ level: 50,
103+ lang: "en",
104+ assumeSpecialUnlocks: true,
105+ });
106+
107+ assert.equal(
108+ safe.arts.some((art) => art.specialUnlock),
109+ false,
110+ );
111+ const specialArts = expanded.arts.filter((art) => art.specialUnlock);
112+ assert.ok(specialArts.length > 0);
113+ assert.ok(specialArts.every((art) => art.fallback));
114+ assert.ok(
115+ expanded.arts.every(
116+ (art) =>
117+ art.weapon === expanded.weapons.ranged ||
118+ art.weapon === expanded.weapons.melee,
119+ ),
120+ );
121+});
122+
123+test("independent weapon overrides rebuild Cross and contextual recommendations", () => {
124+ const classRanks = editClassRank(
125+ ranksFor("galactic-knight", 1),
126+ "full-metal-jaguar",
127+ 10,
128+ );
129+ const automatic = planContextualAvatar({
130+ activeClassId: "galactic-knight",
131+ classRanks,
132+ level: 60,
133+ lang: "en",
134+ assumeSpecialUnlocks: false,
135+ });
136+ const mixed = planContextualAvatar({
137+ activeClassId: "galactic-knight",
138+ classRanks,
139+ level: 60,
140+ lang: "en",
141+ assumeSpecialUnlocks: false,
142+ weaponOverrides: { ranged: "Dual Guns", melee: "Photon Saber" },
143+ });
144+ assert.deepEqual(mixed.weapons, {
145+ ranged: "Dual Guns",
146+ melee: "Photon Saber",
147+ });
148+ assert.notDeepEqual(
149+ mixed.arts.map((art) => art.name),
150+ automatic.arts.map((art) => art.name),
151+ );
152+
153+ const result = recommendContextualTeams(request({
154+ activeClassId: "galactic-knight",
155+ avatarLevel: 60,
156+ classRanks,
157+ weaponOverrides: { ranged: "Dual Guns", melee: "Photon Saber" },
158+ }));
159+ assert.equal(result.kind, "success");
160+ if (result.kind !== "success") return;
161+ assert.deepEqual(result.recommendations[0].roster[0].weapons, mixed.weapons);
162+});
163+
164+test("General keeps Skell aptitude secondary and Skell mode equips relevant Skills", () => {
165+ const generalYelv = evaluateCompanionContext("Yelv", 50, 20, "general");
166+ const skellYelv = evaluateCompanionContext("Yelv", 50, 20, "skell");
167+ assert.ok(generalYelv.roles.skell > 0);
168+ assert.ok(generalYelv.roles.skell < skellYelv.roles.skell);
169+
170+ const result = recommendContextualTeams(request({ combatMode: "skell" }));
171+ assert.equal(result.kind, "success");
172+ if (result.kind !== "success") return;
173+
174+ const primary = result.recommendations[0];
175+ assert.ok(
176+ primary.team.names.some((name) => name === "Alexa" || name === "Yelv"),
177+ );
178+ assert.ok(primary.score.skell > 0);
179+ const skellPilots = primary.roster.filter((member) =>
180+ member.skills.some((skill) => skill.name === "Synchrony" || skill.name === "Stellar Entry"),
181+ );
182+ assert.ok(skellPilots.length > 0);
183+});
184+
185+test("General mode keeps Skell aptitude as a minor tie-breaker", () => {
186+ const withoutLicense = recommendContextualTeams(request({
187+ avatarLevel: 30,
188+ hasSkellLicense: false,
189+ }));
190+ const withLicense = recommendContextualTeams(request({
191+ avatarLevel: 30,
192+ hasSkellLicense: true,
193+ }));
194+ assert.equal(withoutLicense.kind, "success");
195+ assert.equal(withLicense.kind, "success");
196+ if (withoutLicense.kind !== "success" || withLicense.kind !== "success") return;
197+
198+ assert.deepEqual(
199+ withLicense.recommendations[0].team.names,
200+ withoutLicense.recommendations[0].team.names,
201+ );
202+ const licenseDelta = Math.abs(
203+ withLicense.recommendations[0].score.total
204+ - withoutLicense.recommendations[0].score.total,
205+ );
206+ assert.ok(licenseDelta >= 0.05);
207+ assert.ok(licenseDelta < 0.2);
208+});
209+
210+test("contextual Skill upgrade recommendations never exceed level 5", () => {
211+ const result = recommendContextualTeams(request({ avatarLevel: 99 }));
212+ assert.equal(result.kind, "success");
213+ if (result.kind !== "success") return;
214+
215+ for (const recommendation of result.recommendations) {
216+ for (const member of recommendation.roster) {
217+ for (const skill of member.skills) {
218+ assert.ok(skill.recommendedLevel >= 1 && skill.recommendedLevel <= 5);
219+ }
220+ }
221+ }
222+});
223+
224+test("an unavailable low-level party returns an explicit infeasible result", () => {
225+ const result = recommendContextualTeams(
226+ request({
227+ avatarLevel: 1,
228+ hasSkellLicense: false,
229+ personalRanks: [20, 20, 20],
230+ }),
231+ );
232+
233+ assert.equal(result.kind, "infeasible");
234+ if (result.kind !== "infeasible") return;
235+ assert.ok(result.violations.length > 0);
236+ assert.ok(result.relaxations.length > 0);
237+});
238+
239+test("contextual Skell loadouts explain frame and weapon-package synergy", () => {
240+ const loadout = recommendContextualSkellLoadout({
241+ strategy: "duelist",
242+ frame: "XS0730 Amdusias ST",
243+ frameLevel: 30,
244+ combatMode: "skell",
245+ lang: "en",
246+ });
247+
248+ assert.ok(loadout.weapons.length >= 3);
249+ assert.ok(loadout.augments.length >= 3);
250+ assert.ok(loadout.reason.length > 0);
251+ assert.ok(loadout.archetype.length > 0);
252+ assert.ok(loadout.evidence.length > 0);
253+});
254+ test("fixed-kit Skells preserve mandatory weapons", () => {
255+ const loadout = recommendContextualSkellLoadout({
256+ strategy: "jaguar",
257+ frame: "Hraesvelg Lord",
258+ frameLevel: 60,
259+ combatMode: "skell",
260+ lang: "en",
261+ });
262+
263+ assert.equal(loadout.fixedKit, true);
264+ assert.equal(loadout.overdrive, "Jinrai");
265+ assert.ok(loadout.weapons.some((weapon) => weapon.name === "G-Slash"));
266+ assert.ok(loadout.weapons.every((weapon) => weapon.acquisition === "Fixed equipment"));
267+ assert.ok(loadout.packageNotes.some((note) => note.includes("cannot replace")));
268+ });
added src/lib/contextualEngine.ts +1033 -0
new file mode 100644
@@ -0,0 +1,1033 @@
1+import artEffectsData from "@/research/art-effects.json";
2+import { buildStrategies } from "@/src/data/builds";
3+import { companions, companionByName } from "@/src/data/companions";
4+import {
5+ companionCommunityPrior,
6+ evidenceCatalog,
7+ fixedSkellKits,
8+ frameArchetypes,
9+ skillRoleSignals,
10+ strategyArchetype,
11+} from "@/src/data/contextual";
12+import { classProgression } from "@/src/data/progression";
13+import {
14+ legalAvatarWeaponPairs,
15+ planAvatar,
16+ planCompanion,
17+ recommendSkellLoadout,
18+} from "@/src/lib/engine";
19+import type {
20+ ArtSelection,
21+ AvatarPlan,
22+ CombatMode,
23+ CompanionContextEvaluation,
24+ CompanionPlan,
25+ ContextualAvatarRequest,
26+ ContextualRecommendationRequest,
27+ ContextualRecommendationResult,
28+ ContextualRoleVector,
29+ ContextualSkellLoadout,
30+ ContextualSkellLoadoutRequest,
31+ ContextualTeamRecommendation,
32+ ContextualTeamScore,
33+ Lang,
34+ RecommendationVariant,
35+ ResolvedTeamSlot,
36+ RosterMember,
37+ SkillSelection,
38+ TeamResolution,
39+} from "@/src/lib/types";
40+
41+interface ArtEffectRecord {
42+ name: string;
43+ weapon: string;
44+ caption: string;
45+ url: string;
46+ effects: {
47+ debuffs: string[];
48+ buffs: string[];
49+ heals: boolean;
50+ revives: boolean;
51+ tp: boolean;
52+ conditions: string[];
53+ damageAttribute: string | null;
54+ };
55+}
56+
57+interface SignatureArtFact {
58+ name: string;
59+ weapon: string;
60+ companion: string;
61+ source: string;
62+ url: string;
63+}
64+
65+interface EvaluatedCandidate {
66+ names: [string, string, string];
67+ plans: [CompanionPlan, CompanionPlan, CompanionPlan];
68+ roles: ContextualRoleVector;
69+ score: ContextualTeamScore;
70+ team: TeamResolution;
71+ roster: RosterMember[];
72+}
73+
74+const artEffects = artEffectsData.arts as ArtEffectRecord[];
75+const artEffectByName = new Map(artEffects.map((art) => [art.name, art]));
76+const regularArtNames = new Set(
77+ classProgression.flatMap((record) => record.arts.map((art) => art.name)),
78+);
79+const signatureArts: SignatureArtFact[] = companions.flatMap((companion) =>
80+ companion.arts.flatMap((art) => {
81+ if (regularArtNames.has(art.name)) return [];
82+ const effect = artEffectByName.get(art.name);
83+ if (!effect) return [];
84+ return [
85+ {
86+ name: art.name,
87+ weapon: effect.weapon,
88+ companion: companion.name,
89+ source: companion.source,
90+ url: effect.url,
91+ },
92+ ];
93+ }),
94+);
95+const sortedCompanions = [...companions].sort((left, right) =>
96+ left.name.localeCompare(right.name, "en"),
97+);
98+
99+function translated(lang: Lang, english: string, french: string): string {
100+ return lang === "fr" ? french : english;
101+}
102+
103+function clamp(value: number, minimum: number, maximum: number): number {
104+ if (!Number.isFinite(value)) return minimum;
105+ return Math.max(minimum, Math.min(maximum, Math.trunc(value)));
106+}
107+
108+function round(value: number): number {
109+ return Math.round(value * 100) / 100;
110+}
111+
112+
113+function addRoles(
114+ left: ContextualRoleVector,
115+ right: Partial<ContextualRoleVector>,
116+): ContextualRoleVector {
117+ return {
118+ tank: left.tank + (right.tank ?? 0),
119+ support: left.support + (right.support ?? 0),
120+ damage: left.damage + (right.damage ?? 0),
121+ control: left.control + (right.control ?? 0),
122+ sustain: left.sustain + (right.sustain ?? 0),
123+ tp: left.tp + (right.tp ?? 0),
124+ topple: left.topple + (right.topple ?? 0),
125+ skell: left.skell + (right.skell ?? 0),
126+ };
127+}
128+
129+function scaleRoles(
130+ roles: ContextualRoleVector,
131+ scale: number,
132+): ContextualRoleVector {
133+ return {
134+ tank: roles.tank * scale,
135+ support: roles.support * scale,
136+ damage: roles.damage * scale,
137+ control: roles.control * scale,
138+ sustain: roles.sustain * scale,
139+ tp: roles.tp * scale,
140+ topple: roles.topple * scale,
141+ skell: roles.skell * scale,
142+ };
143+}
144+
145+function rolesFromSelections(
146+ base: { tank: number; support: number; damage: number; control: number },
147+ arts: readonly ArtSelection[],
148+ skills: readonly SkillSelection[],
149+ progression: number,
150+): ContextualRoleVector {
151+ let roles: ContextualRoleVector = {
152+ tank: base.tank,
153+ support: base.support,
154+ damage: base.damage,
155+ control: base.control,
156+ sustain: 0,
157+ tp: 0,
158+ topple: 0,
159+ skell: 0,
160+ };
161+
162+ for (const art of arts) {
163+ const effect = artEffectByName.get(art.name);
164+ if (!effect) continue;
165+ if (effect.effects.damageAttribute) roles.damage += 0.16;
166+ if (effect.effects.heals) {
167+ roles.sustain += 0.7;
168+ roles.support += 0.25;
169+ }
170+ if (effect.effects.revives) {
171+ roles.sustain += 1;
172+ roles.support += 0.5;
173+ }
174+ if (effect.effects.tp) roles.tp += 0.35;
175+ roles.support += Math.min(0.6, effect.effects.buffs.length * 0.18);
176+ roles.control += Math.min(0.8, effect.effects.debuffs.length * 0.2);
177+
178+ const searchable = `${effect.caption} ${effect.effects.buffs.join(" ")} ${effect.effects.debuffs.join(" ")}`;
179+ if (/Topple|Stagger|Stun/i.test(searchable)) {
180+ roles.topple += 0.75;
181+ roles.control += 0.35;
182+ }
183+ if (/Decoy|Barrier|Super Armor|resistance|reflect/i.test(searchable)) {
184+ roles.tank += 0.35;
185+ }
186+ if (/TP|Overdrive/i.test(searchable)) roles.tp += 0.2;
187+ }
188+
189+ for (const skill of skills) {
190+ roles = addRoles(roles, skillRoleSignals[skill.name] ?? {});
191+ }
192+
193+ return scaleRoles(roles, 0.58 + progression * 0.42);
194+}
195+
196+function preferredUpgradeLevel(position: number, level: number): number {
197+ const target = position < 2 ? 5 : position < 4 ? 4 : position < 6 ? 3 : 2;
198+ const cap = level >= 50 ? 5 : level >= 35 ? 4 : level >= 20 ? 3 : 2;
199+ return Math.max(1, Math.min(target, cap));
200+}
201+
202+function artUtility(
203+ name: string,
204+ archetype: "tank" | "damage" | "support" | "hybrid",
205+): number {
206+ const effect = artEffectByName.get(name);
207+ if (!effect) return 0;
208+ const { effects, caption } = effect;
209+ let score = effects.damageAttribute ? 1 : 0;
210+ score += effects.tp ? 0.8 : 0;
211+ score += effects.debuffs.length * 0.35;
212+ score += effects.buffs.length * 0.3;
213+ score += effects.heals ? 1.4 : 0;
214+ score += effects.revives ? 1.8 : 0;
215+ score -= effects.conditions.length * 0.08;
216+
217+ if (archetype === "damage") {
218+ score += effects.damageAttribute ? 1.5 : 0;
219+ score += /damage|critical|attack/i.test(caption) ? 0.8 : 0;
220+ } else if (archetype === "tank") {
221+ score += /Decoy|Barrier|Super Armor|resistance|reflect|Topple/i.test(
222+ caption,
223+ )
224+ ? 1.5
225+ : 0;
226+ score += effects.heals ? 0.8 : 0;
227+ } else if (archetype === "support") {
228+ score += effects.heals || effects.revives ? 1.4 : 0;
229+ score += effects.buffs.length * 0.45 + effects.debuffs.length * 0.35;
230+ } else {
231+ score += effects.heals || effects.revives ? 0.6 : 0;
232+ score += effects.damageAttribute ? 0.6 : 0;
233+ }
234+ return score;
235+}
236+
237+function fallbackFor(art: ArtSelection): ArtSelection["fallback"] {
238+ return {
239+ name: art.name,
240+ reason: "Progression-safe regular Art with the equipped weapon",
241+ source: art.source,
242+ };
243+}
244+
245+export function planContextualAvatar(
246+ request: ContextualAvatarRequest,
247+): AvatarPlan {
248+ const lang = request.lang ?? "en";
249+ const plan = planAvatar(
250+ request.activeClassId,
251+ request.classRanks,
252+ undefined,
253+ lang,
254+ request.level,
255+ request.weaponOverrides,
256+ );
257+ if (!request.assumeSpecialUnlocks) return plan;
258+
259+ const archetype = strategyArchetype[plan.strategy];
260+ const regular = plan.arts.map((art, index) => ({
261+ art,
262+ score: 10 - index * 0.35 + artUtility(art.name, archetype),
263+ regular: true,
264+ }));
265+ const compatibleSignatures = signatureArts
266+ .filter(
267+ (art) =>
268+ art.weapon === plan.weapons.ranged || art.weapon === plan.weapons.melee,
269+ )
270+ .map((art) => ({
271+ art: {
272+ name: art.name,
273+ weapon: art.weapon,
274+ unlockRank: 0,
275+ recommendedLevel: 1,
276+ source: {
277+ kind: "companion" as const,
278+ id: art.companion,
279+ url: art.url,
280+ citation: art.source,
281+ },
282+ reason: translated(
283+ lang,
284+ `Signature Art from ${art.companion}; assumes its Affinity Mission is complete`,
285+ `Art signature de ${art.companion} ; suppose sa mission d'affinité terminée`,
286+ ),
287+ specialUnlock: true,
288+ } satisfies ArtSelection,
289+ score: 12 + artUtility(art.name, archetype),
290+ regular: false,
291+ }));
292+
293+ const ranked = [...regular, ...compatibleSignatures].sort(
294+ (left, right) =>
295+ right.score - left.score ||
296+ left.art.name.localeCompare(right.art.name, "en"),
297+ );
298+ const selected = ranked.slice(0, 8);
299+ const selectedRegularNames = new Set(
300+ selected.filter((entry) => entry.regular).map((entry) => entry.art.name),
301+ );
302+ const omittedRegular = regular
303+ .filter((entry) => !selectedRegularNames.has(entry.art.name))
304+ .map((entry) => entry.art);
305+ const safeFallbacks =
306+ omittedRegular.length > 0
307+ ? omittedRegular
308+ : regular.map((entry) => entry.art).slice(-1);
309+ let fallbackIndex = 0;
310+ const arts = selected.map((entry, index): ArtSelection => {
311+ if (entry.regular)
312+ return {
313+ ...entry.art,
314+ recommendedLevel: preferredUpgradeLevel(index, request.level),
315+ };
316+ const fallback = safeFallbacks[fallbackIndex % safeFallbacks.length];
317+ fallbackIndex += 1;
318+ return {
319+ ...entry.art,
320+ recommendedLevel: preferredUpgradeLevel(index, request.level),
321+ fallback: fallback ? fallbackFor(fallback) : undefined,
322+ };
323+ });
324+
325+ return {
326+ ...plan,
327+ arts,
328+ planReasons: [
329+ ...plan.planReasons,
330+ translated(
331+ lang,
332+ `${arts.filter((art) => art.specialUnlock).length} Signature Arts considered; rank gates remain enforced`,
333+ `${arts.filter((art) => art.specialUnlock).length} Arts signature considérés ; les rangs restent respectés`,
334+ ),
335+ ],
336+ };
337+}
338+
339+function companionProgression(plan: CompanionPlan): number {
340+ const artFill = Math.min(1, plan.arts.length / 8);
341+ const skillFill =
342+ plan.skillSlots === 0
343+ ? 1
344+ : Math.min(1, plan.skills.length / plan.skillSlots);
345+ return (artFill + skillFill) / 2;
346+}
347+
348+function firstSourceUrl(citation: string): string {
349+ const match = citation.match(/https?:\/\/[^\s)]+/);
350+ return match?.[0] ?? citation;
351+}
352+
353+function planCompanionForMode(
354+ name: string,
355+ effectiveLevel: number,
356+ personalRank: number,
357+ combatMode: CombatMode,
358+ lang: Lang,
359+): CompanionPlan {
360+ const plan = planCompanion(name, effectiveLevel, personalRank, lang);
361+ if (combatMode !== "skell") return plan;
362+ const companion = companionByName.get(name);
363+ if (!companion) return plan;
364+ const selectedByName = new Map(plan.skills.map((skill) => [skill.name, skill]));
365+ const skellSkills = companion.skills
366+ .filter((skill) =>
367+ skill.rank <= plan.personalRank && (skillRoleSignals[skill.name]?.skell ?? 0) > 0,
368+ )
369+ .sort((left, right) =>
370+ (skillRoleSignals[right.name]?.skell ?? 0)
371+ - (skillRoleSignals[left.name]?.skell ?? 0)
372+ || left.rank - right.rank,
373+ )
374+ .map((skill): SkillSelection =>
375+ selectedByName.get(skill.name) ?? {
376+ name: skill.name,
377+ unlockRank: skill.rank,
378+ recommendedLevel: 1,
379+ source: {
380+ kind: "companion",
381+ id: companion.name,
382+ url: firstSourceUrl(companion.source),
383+ citation: companion.source,
384+ },
385+ reason: translated(
386+ lang,
387+ "Equipped because this Skill is active in Skell combat",
388+ "Équipé car ce Skill est actif en combat Skell",
389+ ),
390+ }
391+ );
392+ if (skellSkills.length === 0) return plan;
393+ const seen = new Set<string>();
394+ const skills = [...skellSkills, ...plan.skills]
395+ .filter((skill) => {
396+ if (seen.has(skill.name)) return false;
397+ seen.add(skill.name);
398+ return true;
399+ })
400+ .slice(0, plan.skillSlots)
401+ .map((skill, index) => ({
402+ ...skill,
403+ recommendedLevel: preferredUpgradeLevel(index, effectiveLevel),
404+ }));
405+ return {
406+ ...plan,
407+ skills,
408+ planReasons: [
409+ ...plan.planReasons,
410+ translated(
411+ lang,
412+ "Skell-active Skills replace ground-only preferences in Skell mode",
413+ "Les Skills actifs en Skell remplacent les préférences terrestres en mode Skell",
414+ ),
415+ ],
416+ };
417+}
418+
419+export function evaluateCompanionContext(
420+ name: string,
421+ effectiveLevel: number,
422+ personalRank: number,
423+ combatMode: CombatMode,
424+): CompanionContextEvaluation {
425+ const plan = planCompanionForMode(
426+ name,
427+ effectiveLevel,
428+ personalRank,
429+ combatMode,
430+ "en",
431+ );
432+ const progression = companionProgression(plan);
433+ const roles = rolesFromSelections(
434+ plan.capabilities,
435+ plan.arts,
436+ plan.skills,
437+ progression,
438+ );
439+ const companion = companionByName.get(name);
440+ const availableSkellAptitude = (companion?.skills ?? [])
441+ .filter((skill) => skill.rank <= plan.personalRank)
442+ .reduce(
443+ (total, skill) => total + (skillRoleSignals[skill.name]?.skell ?? 0),
444+ 0,
445+ );
446+ if (combatMode === "general") roles.skell = availableSkellAptitude * 0.15;
447+ return {
448+ name,
449+ personalRank: plan.personalRank,
450+ progression: round(progression),
451+ roles,
452+ confidence: "medium",
453+ evidence: [evidenceCatalog.skills, evidenceCatalog.communityBuilds],
454+ };
455+}
456+
457+function avatarRoles(plan: AvatarPlan): ContextualRoleVector {
458+ const strategy = buildStrategies[plan.strategy];
459+ return rolesFromSelections(strategy.capabilities, plan.arts, plan.skills, 1);
460+}
461+
462+function targetFor(
463+ archetype: "tank" | "damage" | "support" | "hybrid",
464+): ContextualRoleVector {
465+ if (archetype === "tank") {
466+ return {
467+ tank: 3,
468+ support: 4,
469+ damage: 9,
470+ control: 3,
471+ sustain: 2.5,
472+ tp: 2,
473+ topple: 1,
474+ skell: 0,
475+ };
476+ }
477+ if (archetype === "support") {
478+ return {
479+ tank: 3.5,
480+ support: 4,
481+ damage: 9,
482+ control: 3,
483+ sustain: 2.5,
484+ tp: 2,
485+ topple: 1,
486+ skell: 0,
487+ };
488+ }
489+ if (archetype === "hybrid") {
490+ return {
491+ tank: 3.5,
492+ support: 3.5,
493+ damage: 8.5,
494+ control: 3,
495+ sustain: 2,
496+ tp: 2,
497+ topple: 1,
498+ skell: 0,
499+ };
500+ }
501+ return {
502+ tank: 3.5,
503+ support: 3.5,
504+ damage: 9.5,
505+ control: 3,
506+ sustain: 2.5,
507+ tp: 2,
508+ topple: 1,
509+ skell: 0,
510+ };
511+}
512+
513+function ratio(value: number, target: number): number {
514+ return target === 0 ? 1 : Math.min(1, value / target);
515+}
516+
517+function scoreCandidate(
518+ roles: ContextualRoleVector,
519+ archetype: "tank" | "damage" | "support" | "hybrid",
520+ progression: number,
521+ community: number,
522+ combatMode: CombatMode,
523+ hasSkellLicense: boolean,
524+ variant: RecommendationVariant,
525+): ContextualTeamScore {
526+ const target = targetFor(archetype);
527+ const balance =
528+ 10 *
529+ (ratio(roles.tank, target.tank) * 0.24 +
530+ ratio(roles.support, target.support) * 0.2 +
531+ ratio(roles.damage, target.damage) * 0.34 +
532+ ratio(roles.sustain, target.sustain) * 0.14 +
533+ ratio(roles.control, target.control) * 0.08);
534+ const offense = Math.min(
535+ 10,
536+ roles.damage * 0.82 + roles.tp * 0.45 + roles.control * 0.18,
537+ );
538+ const safety = Math.min(
539+ 10,
540+ roles.tank * 0.9 +
541+ roles.sustain * 1.15 +
542+ roles.support * 0.3 +
543+ roles.control * 0.18,
544+ );
545+ const support = Math.min(
546+ 10,
547+ roles.support * 0.8 + roles.sustain + roles.tp * 0.35,
548+ );
549+ const synergy = Math.min(
550+ 10,
551+ roles.topple * 0.75 + roles.tp * 0.65 + roles.control * 0.3,
552+ );
553+ const skell = hasSkellLicense
554+ ? 10 * (1 - Math.exp(-roles.skell / 4))
555+ : 0;
556+ const progressionScore = progression * 10;
557+ const communityScore = community * 10;
558+
559+ let total: number;
560+ if (variant === "offensive") {
561+ total =
562+ balance * 1.8 +
563+ offense * 3.6 +
564+ safety * 0.7 +
565+ support * 0.5 +
566+ synergy * 1.4 +
567+ progressionScore * 0.8 +
568+ communityScore * 0.18;
569+ } else if (variant === "safe") {
570+ total =
571+ balance * 2.4 +
572+ offense * 0.8 +
573+ safety * 3.2 +
574+ support * 1.4 +
575+ synergy +
576+ progressionScore +
577+ communityScore * 0.18;
578+ } else {
579+ total =
580+ balance * 3.8 +
581+ offense * 1.4 +
582+ safety * 1.6 +
583+ support +
584+ synergy +
585+ progressionScore * 0.9 +
586+ communityScore * 0.2;
587+ }
588+
589+ if (combatMode === "skell") total = total * 0.72 + skell * 4.5;
590+ else total += skell * 0.08;
591+
592+ return {
593+ balance: round(balance),
594+ offense: round(offense),
595+ safety: round(safety),
596+ support: round(support),
597+ synergy: round(synergy),
598+ progression: round(progressionScore),
599+ community: round(communityScore),
600+ skell: round(skell),
601+ total: round(total),
602+ };
603+}
604+
605+function teamKey(names: readonly string[]): string {
606+ return [...names]
607+ .sort((left, right) => left.localeCompare(right, "en"))
608+ .join("|");
609+}
610+
611+function teamMode(
612+ slots: readonly [string, string, string],
613+): TeamResolution["mode"] {
614+ const manualCount = slots.filter(Boolean).length;
615+ return manualCount === 3 ? "manual" : manualCount === 0 ? "auto" : "mixed";
616+}
617+
618+function buildRoster(
619+ avatar: AvatarPlan,
620+ avatarLevel: number,
621+ plans: readonly CompanionPlan[],
622+): RosterMember[] {
623+ const roster: RosterMember[] = [
624+ {
625+ kind: "avatar",
626+ name: "Cross",
627+ className: avatar.activeClassName,
628+ effectiveLevel: avatarLevel,
629+ rank: avatar.activeRank,
630+ weapons: avatar.weapons,
631+ arts: avatar.arts,
632+ skills: avatar.skills,
633+ skillSlots: avatar.skillSlots,
634+ strategy: avatar.strategy,
635+ note: buildStrategies[avatar.strategy].role,
636+ planReasons: avatar.planReasons,
637+ source: classProgression.find(
638+ (record) => record.classId === avatar.activeClassId,
639+ )?.source,
640+ },
641+ ];
642+ for (const plan of plans) {
643+ roster.push({
644+ kind: "companion",
645+ name: plan.name,
646+ className: plan.className,
647+ effectiveLevel: plan.effectiveLevel,
648+ rank: plan.personalRank,
649+ weapons: plan.weapons,
650+ arts: plan.arts,
651+ skills: plan.skills,
652+ skillSlots: plan.skillSlots,
653+ strategy: plan.strategy,
654+ note: plan.note,
655+ planReasons: plan.planReasons,
656+ source: plan.source,
657+ });
658+ }
659+ return roster;
660+}
661+
662+function legacyTeamScore(roles: ContextualRoleVector, community: number) {
663+ const core = [roles.tank, roles.support, roles.damage];
664+ return {
665+ minimumCoreCoverage: Math.min(...core),
666+ coveredCoreAxes: core.filter((value) => value > 0).length,
667+ cappedCoreDepth: core.reduce(
668+ (total, value) => total + Math.min(2, value),
669+ 0,
670+ ),
671+ communityFit: community,
672+ control: roles.control,
673+ total: roles.tank + roles.support + roles.damage + roles.control,
674+ };
675+}
676+
677+function variantLabel(
678+ variant: RecommendationVariant,
679+): readonly [string, string] {
680+ if (variant === "offensive")
681+ return ["Offensive alternative", "Alternative offensive"];
682+ if (variant === "safe") return ["Safe alternative", "Alternative sûre"];
683+ return ["Recommended party", "Équipe recommandée"];
684+}
685+
686+function variantReasons(
687+ variant: RecommendationVariant,
688+ score: ContextualTeamScore,
689+ lang: Lang,
690+ archetype: "tank" | "damage" | "support" | "hybrid",
691+): string[] {
692+ if (variant === "offensive") {
693+ return [
694+ translated(
695+ lang,
696+ `Prioritizes damage and TP tempo while retaining viable role coverage (offense ${score.offense}/10)`,
697+ `Privilégie dégâts et tempo TP tout en gardant une couverture viable (offense ${score.offense}/10)`,
698+ ),
699+ ];
700+ }
701+ if (variant === "safe") {
702+ return [
703+ translated(
704+ lang,
705+ `Prioritizes sustain and defensive tolerance (safety ${score.safety}/10)`,
706+ `Privilégie sustain et tolérance défensive (sécurité ${score.safety}/10)`,
707+ ),
708+ ];
709+ }
710+ return [
711+ translated(
712+ lang,
713+ `Balances companions around Cross's ${archetype} profile (balance ${score.balance}/10)`,
714+ `Équilibre les compagnons autour du profil ${archetype} de Cross (équilibre ${score.balance}/10)`,
715+ ),
716+ ];
717+}
718+
719+export function recommendContextualTeams(
720+ request: ContextualRecommendationRequest,
721+): ContextualRecommendationResult {
722+ const lang = request.lang ?? "en";
723+ const level = clamp(request.avatarLevel, 1, 99);
724+ const violations: string[] = [];
725+ const manual = new Set<string>();
726+ for (const name of request.slots) {
727+ if (!name) continue;
728+ const companion = companionByName.get(name);
729+ if (!companion) violations.push(`Unknown companion: ${name}`);
730+ else if (companion.startLevel > level)
731+ violations.push(`${name} requires level ${companion.startLevel}`);
732+ if (manual.has(name)) violations.push(`Duplicate companion: ${name}`);
733+ manual.add(name);
734+ }
735+ if (violations.length > 0) {
736+ return {
737+ kind: "infeasible",
738+ violations,
739+ relaxations: [
740+ translated(
741+ lang,
742+ "Raise the squad level or clear the invalid manual slot",
743+ "Augmenter le niveau ou corriger le slot manuel",
744+ ),
745+ ],
746+ };
747+ }
748+
749+ const eligible = sortedCompanions.filter(
750+ (companion) => companion.startLevel <= level && !manual.has(companion.name),
751+ );
752+ const autoSlots = request.slots.filter((name) => !name).length;
753+ if (eligible.length < autoSlots) {
754+ return {
755+ kind: "infeasible",
756+ violations: [
757+ translated(
758+ lang,
759+ "Not enough level-eligible companions to fill the party",
760+ "Pas assez de compagnons éligibles pour remplir l'équipe",
761+ ),
762+ ],
763+ relaxations: [
764+ translated(
765+ lang,
766+ "Raise the squad level to unlock more companions",
767+ "Augmenter le niveau d'équipe pour débloquer plus de compagnons",
768+ ),
769+ ],
770+ };
771+ }
772+ const requestedWeapons = request.weaponOverrides;
773+ if (requestedWeapons?.ranged || requestedWeapons?.melee) {
774+ const legalPairs = legalAvatarWeaponPairs(request.activeClassId, request.classRanks);
775+ const legalOverride = legalPairs.some((pair) =>
776+ (!requestedWeapons.ranged || pair.ranged === requestedWeapons.ranged)
777+ && (!requestedWeapons.melee || pair.melee === requestedWeapons.melee));
778+ if (!legalOverride) {
779+ return {
780+ kind: "infeasible",
781+ violations: [translated(
782+ lang,
783+ "Selected weapon override is not legal for the active class and mastered final classes",
784+ "La sélection d'armes n'est pas légale pour la classe active et les classes finales maîtrisées",
785+ )],
786+ relaxations: [translated(
787+ lang,
788+ "Clear the weapon override or master the corresponding final class at rank 10",
789+ "Retirer la sélection d'armes ou maîtriser la classe finale correspondante au rang 10",
790+ )],
791+ };
792+ }
793+ }
794+
795+ const avatar = planContextualAvatar({
796+ activeClassId: request.activeClassId,
797+ classRanks: request.classRanks,
798+ weaponOverrides: request.weaponOverrides,
799+ level,
800+ lang,
801+ assumeSpecialUnlocks: request.assumeSpecialUnlocks,
802+ });
803+ const crossRoles = avatarRoles(avatar);
804+ const archetype = strategyArchetype[avatar.strategy];
805+ const planCache = new Map<string, CompanionPlan>();
806+ const profileCache = new Map<string, CompanionContextEvaluation>();
807+ const planFor = (name: string, slotIndex: number): CompanionPlan => {
808+ const key = `${slotIndex}:${name}`;
809+ let plan = planCache.get(key);
810+ if (!plan) {
811+ plan = planCompanionForMode(
812+ name,
813+ level,
814+ request.personalRanks[slotIndex],
815+ request.combatMode,
816+ lang,
817+ );
818+ planCache.set(key, plan);
819+ }
820+ return plan;
821+ };
822+ const profileFor = (
823+ name: string,
824+ slotIndex: number,
825+ ): CompanionContextEvaluation => {
826+ const key = `${slotIndex}:${name}`;
827+ let profile = profileCache.get(key);
828+ if (!profile) {
829+ profile = evaluateCompanionContext(
830+ name,
831+ level,
832+ request.personalRanks[slotIndex],
833+ request.combatMode,
834+ );
835+ profileCache.set(key, profile);
836+ }
837+ return profile;
838+ };
839+
840+ const assignments: [string, string, string][] = [];
841+ const current: string[] = [];
842+ const used = new Set<string>();
843+ const enumerate = (index: number): void => {
844+ if (index === 3) {
845+ assignments.push([...current] as [string, string, string]);
846+ return;
847+ }
848+ const manualName = request.slots[index];
849+ const choices = manualName
850+ ? [manualName]
851+ : eligible.map((companion) => companion.name);
852+ for (const name of choices) {
853+ if (used.has(name)) continue;
854+ used.add(name);
855+ current.push(name);
856+ enumerate(index + 1);
857+ current.pop();
858+ used.delete(name);
859+ }
860+ };
861+ enumerate(0);
862+
863+ const evaluateFor = (
864+ names: [string, string, string],
865+ variant: RecommendationVariant,
866+ ): EvaluatedCandidate => {
867+ const plans = names.map((name, index) => planFor(name, index)) as [
868+ CompanionPlan,
869+ CompanionPlan,
870+ CompanionPlan,
871+ ];
872+ const profiles = names.map((name, index) => profileFor(name, index));
873+ const roles = profiles.reduce(
874+ (total, profile) => addRoles(total, profile.roles),
875+ crossRoles,
876+ );
877+ const progression =
878+ profiles.reduce((total, profile) => total + profile.progression, 0) /
879+ profiles.length;
880+ const community =
881+ names.reduce(
882+ (total, name) => total + (companionCommunityPrior[name] ?? 0.5),
883+ 0,
884+ ) / names.length;
885+ const score = scoreCandidate(
886+ roles,
887+ archetype,
888+ progression,
889+ community,
890+ request.combatMode,
891+ request.hasSkellLicense,
892+ variant,
893+ );
894+ const slots = names.map((name, index) => ({
895+ name,
896+ effectiveLevel: level,
897+ provenance: request.slots[index]
898+ ? ("manual" as const)
899+ : ("auto" as const),
900+ })) as [ResolvedTeamSlot, ResolvedTeamSlot, ResolvedTeamSlot];
901+ const coverage = {
902+ tank: round(roles.tank),
903+ support: round(roles.support),
904+ damage: round(roles.damage),
905+ control: round(roles.control),
906+ };
907+ const team: TeamResolution = {
908+ names,
909+ slots,
910+ coverage,
911+ score: legacyTeamScore(roles, community),
912+ mode: teamMode(request.slots),
913+ };
914+ return {
915+ names,
916+ plans,
917+ roles,
918+ score,
919+ team,
920+ roster: buildRoster(avatar, level, plans),
921+ };
922+ };
923+
924+ const selected: ContextualTeamRecommendation[] = [];
925+ const usedTeams = new Set<string>();
926+ const missingAlternatives: RecommendationVariant[] = [];
927+ for (const variant of ["primary", "offensive", "safe"] as const) {
928+ const candidates = assignments
929+ .map((names) => evaluateFor(names, variant))
930+ .sort(
931+ (left, right) =>
932+ right.score.total - left.score.total ||
933+ left.names.join("|").localeCompare(right.names.join("|"), "en"),
934+ );
935+ const candidate = candidates.find(
936+ (entry) => !usedTeams.has(teamKey(entry.names)),
937+ );
938+ if (!candidate) {
939+ missingAlternatives.push(variant);
940+ continue;
941+ }
942+ usedTeams.add(teamKey(candidate.names));
943+ selected.push({
944+ variant,
945+ label: variantLabel(variant),
946+ team: candidate.team,
947+ roster: candidate.roster,
948+ score: candidate.score,
949+ roles: candidate.roles,
950+ reasons: variantReasons(variant, candidate.score, lang, archetype),
951+ confidence: "medium",
952+ evidence: [
953+ evidenceCatalog.battleArts,
954+ evidenceCatalog.skills,
955+ evidenceCatalog.overdrive,
956+ evidenceCatalog.communityTeams,
957+ ],
958+ });
959+ }
960+
961+ return selected.length === 0
962+ ? {
963+ kind: "infeasible",
964+ violations: [
965+ translated(
966+ lang,
967+ "No valid non-duplicate party assignment",
968+ "Aucune affectation d'équipe valide sans doublon",
969+ ),
970+ ],
971+ relaxations: [
972+ translated(
973+ lang,
974+ "Clear a manual slot or raise the squad level",
975+ "Libérer un slot manuel ou augmenter le niveau",
976+ ),
977+ ],
978+ }
979+ : { kind: "success", recommendations: selected, missingAlternatives };
980+}
981+
982+export function recommendContextualSkellLoadout(
983+ request: ContextualSkellLoadoutRequest,
984+): ContextualSkellLoadout {
985+ const lang = request.lang ?? "en";
986+ const textIndex = lang === "fr" ? 1 : 0;
987+ const base = recommendSkellLoadout(request.strategy, request.frameLevel);
988+ const archetype =
989+ frameArchetypes.find((entry) => request.frame.includes(entry.key)) ??
990+ frameArchetypes.find((entry) => entry.key === "Urban")!;
991+ const fixedKit = fixedSkellKits.find((kit) =>
992+ kit.matches.some((match) => request.frame.includes(match)),
993+ );
994+ const modeNote =
995+ request.combatMode === "skell"
996+ ? translated(
997+ lang,
998+ "Optimized as the active combat package",
999+ "Optimisé comme package de combat principal",
1000+ )
1001+ : translated(
1002+ lang,
1003+ "Balanced as an accessible secondary package",
1004+ "Équilibré comme package secondaire accessible",
1005+ );
1006+ const packageNotes = fixedKit
1007+ ? fixedKit.notes.map((note) => note[textIndex])
1008+ : [
1009+ translated(
1010+ lang,
1011+ `${base.weapons.length} available mount families selected without inventing owned drops`,
1012+ `${base.weapons.length} familles de montures disponibles sélectionnées sans inventer de drops possédés`,
1013+ ),
1014+ translated(
1015+ lang,
1016+ "Treat frame association as a preference; replace a mount when encounter utility outweighs attribute focus",
1017+ "Traiter l'association au frame comme une préférence ; remplacer une monture si l'utilité de rencontre prime sur l'attribut",
1018+ ),
1019+ ];
1020+ return {
1021+ weapons: fixedKit?.weapons ?? base.weapons,
1022+ augments: fixedKit?.augments ?? base.augments,
1023+ archetype: archetype.label[textIndex],
1024+ reason: `${archetype.reason[textIndex]} ${modeNote}. Overdrive: ${fixedKit?.overdrive ?? archetype.overdrive}.`,
1025+ fixedKit: Boolean(fixedKit),
1026+ overdrive: fixedKit?.overdrive ?? archetype.overdrive,
1027+ fuelGpPlan: archetype.fuelGpPlan[textIndex],
1028+ packageNotes,
1029+ evidence: fixedKit
1030+ ? [evidenceCatalog.skell, evidenceCatalog.skellWeapons, fixedKit.evidence]
1031+ : [evidenceCatalog.skell, evidenceCatalog.skellWeapons],
1032+ };
1033+}
new file mode 100644
@@ -0,0 +1,1033 @@
1+import artEffectsData from "@/research/art-effects.json";
2+import { buildStrategies } from "@/src/data/builds";
3+import { companions, companionByName } from "@/src/data/companions";
4+import {
5+ companionCommunityPrior,
6+ evidenceCatalog,
7+ fixedSkellKits,
8+ frameArchetypes,
9+ skillRoleSignals,
10+ strategyArchetype,
11+} from "@/src/data/contextual";
12+import { classProgression } from "@/src/data/progression";
13+import {
14+ legalAvatarWeaponPairs,
15+ planAvatar,
16+ planCompanion,
17+ recommendSkellLoadout,
18+} from "@/src/lib/engine";
19+import type {
20+ ArtSelection,
21+ AvatarPlan,
22+ CombatMode,
23+ CompanionContextEvaluation,
24+ CompanionPlan,
25+ ContextualAvatarRequest,
26+ ContextualRecommendationRequest,
27+ ContextualRecommendationResult,
28+ ContextualRoleVector,
29+ ContextualSkellLoadout,
30+ ContextualSkellLoadoutRequest,
31+ ContextualTeamRecommendation,
32+ ContextualTeamScore,
33+ Lang,
34+ RecommendationVariant,
35+ ResolvedTeamSlot,
36+ RosterMember,
37+ SkillSelection,
38+ TeamResolution,
39+} from "@/src/lib/types";
40+
41+interface ArtEffectRecord {
42+ name: string;
43+ weapon: string;
44+ caption: string;
45+ url: string;
46+ effects: {
47+ debuffs: string[];
48+ buffs: string[];
49+ heals: boolean;
50+ revives: boolean;
51+ tp: boolean;
52+ conditions: string[];
53+ damageAttribute: string | null;
54+ };
55+}
56+
57+interface SignatureArtFact {
58+ name: string;
59+ weapon: string;
60+ companion: string;
61+ source: string;
62+ url: string;
63+}
64+
65+interface EvaluatedCandidate {
66+ names: [string, string, string];
67+ plans: [CompanionPlan, CompanionPlan, CompanionPlan];
68+ roles: ContextualRoleVector;
69+ score: ContextualTeamScore;
70+ team: TeamResolution;
71+ roster: RosterMember[];
72+}
73+
74+const artEffects = artEffectsData.arts as ArtEffectRecord[];
75+const artEffectByName = new Map(artEffects.map((art) => [art.name, art]));
76+const regularArtNames = new Set(
77+ classProgression.flatMap((record) => record.arts.map((art) => art.name)),
78+);
79+const signatureArts: SignatureArtFact[] = companions.flatMap((companion) =>
80+ companion.arts.flatMap((art) => {
81+ if (regularArtNames.has(art.name)) return [];
82+ const effect = artEffectByName.get(art.name);
83+ if (!effect) return [];
84+ return [
85+ {
86+ name: art.name,
87+ weapon: effect.weapon,
88+ companion: companion.name,
89+ source: companion.source,
90+ url: effect.url,
91+ },
92+ ];
93+ }),
94+);
95+const sortedCompanions = [...companions].sort((left, right) =>
96+ left.name.localeCompare(right.name, "en"),
97+);
98+
99+function translated(lang: Lang, english: string, french: string): string {
100+ return lang === "fr" ? french : english;
101+}
102+
103+function clamp(value: number, minimum: number, maximum: number): number {
104+ if (!Number.isFinite(value)) return minimum;
105+ return Math.max(minimum, Math.min(maximum, Math.trunc(value)));
106+}
107+
108+function round(value: number): number {
109+ return Math.round(value * 100) / 100;
110+}
111+
112+
113+function addRoles(
114+ left: ContextualRoleVector,
115+ right: Partial<ContextualRoleVector>,
116+): ContextualRoleVector {
117+ return {
118+ tank: left.tank + (right.tank ?? 0),
119+ support: left.support + (right.support ?? 0),
120+ damage: left.damage + (right.damage ?? 0),
121+ control: left.control + (right.control ?? 0),
122+ sustain: left.sustain + (right.sustain ?? 0),
123+ tp: left.tp + (right.tp ?? 0),
124+ topple: left.topple + (right.topple ?? 0),
125+ skell: left.skell + (right.skell ?? 0),
126+ };
127+}
128+
129+function scaleRoles(
130+ roles: ContextualRoleVector,
131+ scale: number,
132+): ContextualRoleVector {
133+ return {
134+ tank: roles.tank * scale,
135+ support: roles.support * scale,
136+ damage: roles.damage * scale,
137+ control: roles.control * scale,
138+ sustain: roles.sustain * scale,
139+ tp: roles.tp * scale,
140+ topple: roles.topple * scale,
141+ skell: roles.skell * scale,
142+ };
143+}
144+
145+function rolesFromSelections(
146+ base: { tank: number; support: number; damage: number; control: number },
147+ arts: readonly ArtSelection[],
148+ skills: readonly SkillSelection[],
149+ progression: number,
150+): ContextualRoleVector {
151+ let roles: ContextualRoleVector = {
152+ tank: base.tank,
153+ support: base.support,
154+ damage: base.damage,
155+ control: base.control,
156+ sustain: 0,
157+ tp: 0,
158+ topple: 0,
159+ skell: 0,
160+ };
161+
162+ for (const art of arts) {
163+ const effect = artEffectByName.get(art.name);
164+ if (!effect) continue;
165+ if (effect.effects.damageAttribute) roles.damage += 0.16;
166+ if (effect.effects.heals) {
167+ roles.sustain += 0.7;
168+ roles.support += 0.25;
169+ }
170+ if (effect.effects.revives) {
171+ roles.sustain += 1;
172+ roles.support += 0.5;
173+ }
174+ if (effect.effects.tp) roles.tp += 0.35;
175+ roles.support += Math.min(0.6, effect.effects.buffs.length * 0.18);
176+ roles.control += Math.min(0.8, effect.effects.debuffs.length * 0.2);
177+
178+ const searchable = `${effect.caption} ${effect.effects.buffs.join(" ")} ${effect.effects.debuffs.join(" ")}`;
179+ if (/Topple|Stagger|Stun/i.test(searchable)) {
180+ roles.topple += 0.75;
181+ roles.control += 0.35;
182+ }
183+ if (/Decoy|Barrier|Super Armor|resistance|reflect/i.test(searchable)) {
184+ roles.tank += 0.35;
185+ }
186+ if (/TP|Overdrive/i.test(searchable)) roles.tp += 0.2;
187+ }
188+
189+ for (const skill of skills) {
190+ roles = addRoles(roles, skillRoleSignals[skill.name] ?? {});
191+ }
192+
193+ return scaleRoles(roles, 0.58 + progression * 0.42);
194+}
195+
196+function preferredUpgradeLevel(position: number, level: number): number {
197+ const target = position < 2 ? 5 : position < 4 ? 4 : position < 6 ? 3 : 2;
198+ const cap = level >= 50 ? 5 : level >= 35 ? 4 : level >= 20 ? 3 : 2;
199+ return Math.max(1, Math.min(target, cap));
200+}
201+
202+function artUtility(
203+ name: string,
204+ archetype: "tank" | "damage" | "support" | "hybrid",
205+): number {
206+ const effect = artEffectByName.get(name);
207+ if (!effect) return 0;
208+ const { effects, caption } = effect;
209+ let score = effects.damageAttribute ? 1 : 0;
210+ score += effects.tp ? 0.8 : 0;
211+ score += effects.debuffs.length * 0.35;
212+ score += effects.buffs.length * 0.3;
213+ score += effects.heals ? 1.4 : 0;
214+ score += effects.revives ? 1.8 : 0;
215+ score -= effects.conditions.length * 0.08;
216+
217+ if (archetype === "damage") {
218+ score += effects.damageAttribute ? 1.5 : 0;
219+ score += /damage|critical|attack/i.test(caption) ? 0.8 : 0;
220+ } else if (archetype === "tank") {
221+ score += /Decoy|Barrier|Super Armor|resistance|reflect|Topple/i.test(
222+ caption,
223+ )
224+ ? 1.5
225+ : 0;
226+ score += effects.heals ? 0.8 : 0;
227+ } else if (archetype === "support") {
228+ score += effects.heals || effects.revives ? 1.4 : 0;
229+ score += effects.buffs.length * 0.45 + effects.debuffs.length * 0.35;
230+ } else {
231+ score += effects.heals || effects.revives ? 0.6 : 0;
232+ score += effects.damageAttribute ? 0.6 : 0;
233+ }
234+ return score;
235+}
236+
237+function fallbackFor(art: ArtSelection): ArtSelection["fallback"] {
238+ return {
239+ name: art.name,
240+ reason: "Progression-safe regular Art with the equipped weapon",
241+ source: art.source,
242+ };
243+}
244+
245+export function planContextualAvatar(
246+ request: ContextualAvatarRequest,
247+): AvatarPlan {
248+ const lang = request.lang ?? "en";
249+ const plan = planAvatar(
250+ request.activeClassId,
251+ request.classRanks,
252+ undefined,
253+ lang,
254+ request.level,
255+ request.weaponOverrides,
256+ );
257+ if (!request.assumeSpecialUnlocks) return plan;
258+
259+ const archetype = strategyArchetype[plan.strategy];
260+ const regular = plan.arts.map((art, index) => ({
261+ art,
262+ score: 10 - index * 0.35 + artUtility(art.name, archetype),
263+ regular: true,
264+ }));
265+ const compatibleSignatures = signatureArts
266+ .filter(
267+ (art) =>
268+ art.weapon === plan.weapons.ranged || art.weapon === plan.weapons.melee,
269+ )
270+ .map((art) => ({
271+ art: {
272+ name: art.name,
273+ weapon: art.weapon,
274+ unlockRank: 0,
275+ recommendedLevel: 1,
276+ source: {
277+ kind: "companion" as const,
278+ id: art.companion,
279+ url: art.url,
280+ citation: art.source,
281+ },
282+ reason: translated(
283+ lang,
284+ `Signature Art from ${art.companion}; assumes its Affinity Mission is complete`,
285+ `Art signature de ${art.companion} ; suppose sa mission d'affinité terminée`,
286+ ),
287+ specialUnlock: true,
288+ } satisfies ArtSelection,
289+ score: 12 + artUtility(art.name, archetype),
290+ regular: false,
291+ }));
292+
293+ const ranked = [...regular, ...compatibleSignatures].sort(
294+ (left, right) =>
295+ right.score - left.score ||
296+ left.art.name.localeCompare(right.art.name, "en"),
297+ );
298+ const selected = ranked.slice(0, 8);
299+ const selectedRegularNames = new Set(
300+ selected.filter((entry) => entry.regular).map((entry) => entry.art.name),
301+ );
302+ const omittedRegular = regular
303+ .filter((entry) => !selectedRegularNames.has(entry.art.name))
304+ .map((entry) => entry.art);
305+ const safeFallbacks =
306+ omittedRegular.length > 0
307+ ? omittedRegular
308+ : regular.map((entry) => entry.art).slice(-1);
309+ let fallbackIndex = 0;
310+ const arts = selected.map((entry, index): ArtSelection => {
311+ if (entry.regular)
312+ return {
313+ ...entry.art,
314+ recommendedLevel: preferredUpgradeLevel(index, request.level),
315+ };
316+ const fallback = safeFallbacks[fallbackIndex % safeFallbacks.length];
317+ fallbackIndex += 1;
318+ return {
319+ ...entry.art,
320+ recommendedLevel: preferredUpgradeLevel(index, request.level),
321+ fallback: fallback ? fallbackFor(fallback) : undefined,
322+ };
323+ });
324+
325+ return {
326+ ...plan,
327+ arts,
328+ planReasons: [
329+ ...plan.planReasons,
330+ translated(
331+ lang,
332+ `${arts.filter((art) => art.specialUnlock).length} Signature Arts considered; rank gates remain enforced`,
333+ `${arts.filter((art) => art.specialUnlock).length} Arts signature considérés ; les rangs restent respectés`,
334+ ),
335+ ],
336+ };
337+}
338+
339+function companionProgression(plan: CompanionPlan): number {
340+ const artFill = Math.min(1, plan.arts.length / 8);
341+ const skillFill =
342+ plan.skillSlots === 0
343+ ? 1
344+ : Math.min(1, plan.skills.length / plan.skillSlots);
345+ return (artFill + skillFill) / 2;
346+}
347+
348+function firstSourceUrl(citation: string): string {
349+ const match = citation.match(/https?:\/\/[^\s)]+/);
350+ return match?.[0] ?? citation;
351+}
352+
353+function planCompanionForMode(
354+ name: string,
355+ effectiveLevel: number,
356+ personalRank: number,
357+ combatMode: CombatMode,
358+ lang: Lang,
359+): CompanionPlan {
360+ const plan = planCompanion(name, effectiveLevel, personalRank, lang);
361+ if (combatMode !== "skell") return plan;
362+ const companion = companionByName.get(name);
363+ if (!companion) return plan;
364+ const selectedByName = new Map(plan.skills.map((skill) => [skill.name, skill]));
365+ const skellSkills = companion.skills
366+ .filter((skill) =>
367+ skill.rank <= plan.personalRank && (skillRoleSignals[skill.name]?.skell ?? 0) > 0,
368+ )
369+ .sort((left, right) =>
370+ (skillRoleSignals[right.name]?.skell ?? 0)
371+ - (skillRoleSignals[left.name]?.skell ?? 0)
372+ || left.rank - right.rank,
373+ )
374+ .map((skill): SkillSelection =>
375+ selectedByName.get(skill.name) ?? {
376+ name: skill.name,
377+ unlockRank: skill.rank,
378+ recommendedLevel: 1,
379+ source: {
380+ kind: "companion",
381+ id: companion.name,
382+ url: firstSourceUrl(companion.source),
383+ citation: companion.source,
384+ },
385+ reason: translated(
386+ lang,
387+ "Equipped because this Skill is active in Skell combat",
388+ "Équipé car ce Skill est actif en combat Skell",
389+ ),
390+ }
391+ );
392+ if (skellSkills.length === 0) return plan;
393+ const seen = new Set<string>();
394+ const skills = [...skellSkills, ...plan.skills]
395+ .filter((skill) => {
396+ if (seen.has(skill.name)) return false;
397+ seen.add(skill.name);
398+ return true;
399+ })
400+ .slice(0, plan.skillSlots)
401+ .map((skill, index) => ({
402+ ...skill,
403+ recommendedLevel: preferredUpgradeLevel(index, effectiveLevel),
404+ }));
405+ return {
406+ ...plan,
407+ skills,
408+ planReasons: [
409+ ...plan.planReasons,
410+ translated(
411+ lang,
412+ "Skell-active Skills replace ground-only preferences in Skell mode",
413+ "Les Skills actifs en Skell remplacent les préférences terrestres en mode Skell",
414+ ),
415+ ],
416+ };
417+}
418+
419+export function evaluateCompanionContext(
420+ name: string,
421+ effectiveLevel: number,
422+ personalRank: number,
423+ combatMode: CombatMode,
424+): CompanionContextEvaluation {
425+ const plan = planCompanionForMode(
426+ name,
427+ effectiveLevel,
428+ personalRank,
429+ combatMode,
430+ "en",
431+ );
432+ const progression = companionProgression(plan);
433+ const roles = rolesFromSelections(
434+ plan.capabilities,
435+ plan.arts,
436+ plan.skills,
437+ progression,
438+ );
439+ const companion = companionByName.get(name);
440+ const availableSkellAptitude = (companion?.skills ?? [])
441+ .filter((skill) => skill.rank <= plan.personalRank)
442+ .reduce(
443+ (total, skill) => total + (skillRoleSignals[skill.name]?.skell ?? 0),
444+ 0,
445+ );
446+ if (combatMode === "general") roles.skell = availableSkellAptitude * 0.15;
447+ return {
448+ name,
449+ personalRank: plan.personalRank,
450+ progression: round(progression),
451+ roles,
452+ confidence: "medium",
453+ evidence: [evidenceCatalog.skills, evidenceCatalog.communityBuilds],
454+ };
455+}
456+
457+function avatarRoles(plan: AvatarPlan): ContextualRoleVector {
458+ const strategy = buildStrategies[plan.strategy];
459+ return rolesFromSelections(strategy.capabilities, plan.arts, plan.skills, 1);
460+}
461+
462+function targetFor(
463+ archetype: "tank" | "damage" | "support" | "hybrid",
464+): ContextualRoleVector {
465+ if (archetype === "tank") {
466+ return {
467+ tank: 3,
468+ support: 4,
469+ damage: 9,
470+ control: 3,
471+ sustain: 2.5,
472+ tp: 2,
473+ topple: 1,
474+ skell: 0,
475+ };
476+ }
477+ if (archetype === "support") {
478+ return {
479+ tank: 3.5,
480+ support: 4,
481+ damage: 9,
482+ control: 3,
483+ sustain: 2.5,
484+ tp: 2,
485+ topple: 1,
486+ skell: 0,
487+ };
488+ }
489+ if (archetype === "hybrid") {
490+ return {
491+ tank: 3.5,
492+ support: 3.5,
493+ damage: 8.5,
494+ control: 3,
495+ sustain: 2,
496+ tp: 2,
497+ topple: 1,
498+ skell: 0,
499+ };
500+ }
501+ return {
502+ tank: 3.5,
503+ support: 3.5,
504+ damage: 9.5,
505+ control: 3,
506+ sustain: 2.5,
507+ tp: 2,
508+ topple: 1,
509+ skell: 0,
510+ };
511+}
512+
513+function ratio(value: number, target: number): number {
514+ return target === 0 ? 1 : Math.min(1, value / target);
515+}
516+
517+function scoreCandidate(
518+ roles: ContextualRoleVector,
519+ archetype: "tank" | "damage" | "support" | "hybrid",
520+ progression: number,
521+ community: number,
522+ combatMode: CombatMode,
523+ hasSkellLicense: boolean,
524+ variant: RecommendationVariant,
525+): ContextualTeamScore {
526+ const target = targetFor(archetype);
527+ const balance =
528+ 10 *
529+ (ratio(roles.tank, target.tank) * 0.24 +
530+ ratio(roles.support, target.support) * 0.2 +
531+ ratio(roles.damage, target.damage) * 0.34 +
532+ ratio(roles.sustain, target.sustain) * 0.14 +
533+ ratio(roles.control, target.control) * 0.08);
534+ const offense = Math.min(
535+ 10,
536+ roles.damage * 0.82 + roles.tp * 0.45 + roles.control * 0.18,
537+ );
538+ const safety = Math.min(
539+ 10,
540+ roles.tank * 0.9 +
541+ roles.sustain * 1.15 +
542+ roles.support * 0.3 +
543+ roles.control * 0.18,
544+ );
545+ const support = Math.min(
546+ 10,
547+ roles.support * 0.8 + roles.sustain + roles.tp * 0.35,
548+ );
549+ const synergy = Math.min(
550+ 10,
551+ roles.topple * 0.75 + roles.tp * 0.65 + roles.control * 0.3,
552+ );
553+ const skell = hasSkellLicense
554+ ? 10 * (1 - Math.exp(-roles.skell / 4))
555+ : 0;
556+ const progressionScore = progression * 10;
557+ const communityScore = community * 10;
558+
559+ let total: number;
560+ if (variant === "offensive") {
561+ total =
562+ balance * 1.8 +
563+ offense * 3.6 +
564+ safety * 0.7 +
565+ support * 0.5 +
566+ synergy * 1.4 +
567+ progressionScore * 0.8 +
568+ communityScore * 0.18;
569+ } else if (variant === "safe") {
570+ total =
571+ balance * 2.4 +
572+ offense * 0.8 +
573+ safety * 3.2 +
574+ support * 1.4 +
575+ synergy +
576+ progressionScore +
577+ communityScore * 0.18;
578+ } else {
579+ total =
580+ balance * 3.8 +
581+ offense * 1.4 +
582+ safety * 1.6 +
583+ support +
584+ synergy +
585+ progressionScore * 0.9 +
586+ communityScore * 0.2;
587+ }
588+
589+ if (combatMode === "skell") total = total * 0.72 + skell * 4.5;
590+ else total += skell * 0.08;
591+
592+ return {
593+ balance: round(balance),
594+ offense: round(offense),
595+ safety: round(safety),
596+ support: round(support),
597+ synergy: round(synergy),
598+ progression: round(progressionScore),
599+ community: round(communityScore),
600+ skell: round(skell),
601+ total: round(total),
602+ };
603+}
604+
605+function teamKey(names: readonly string[]): string {
606+ return [...names]
607+ .sort((left, right) => left.localeCompare(right, "en"))
608+ .join("|");
609+}
610+
611+function teamMode(
612+ slots: readonly [string, string, string],
613+): TeamResolution["mode"] {
614+ const manualCount = slots.filter(Boolean).length;
615+ return manualCount === 3 ? "manual" : manualCount === 0 ? "auto" : "mixed";
616+}
617+
618+function buildRoster(
619+ avatar: AvatarPlan,
620+ avatarLevel: number,
621+ plans: readonly CompanionPlan[],
622+): RosterMember[] {
623+ const roster: RosterMember[] = [
624+ {
625+ kind: "avatar",
626+ name: "Cross",
627+ className: avatar.activeClassName,
628+ effectiveLevel: avatarLevel,
629+ rank: avatar.activeRank,
630+ weapons: avatar.weapons,
631+ arts: avatar.arts,
632+ skills: avatar.skills,
633+ skillSlots: avatar.skillSlots,
634+ strategy: avatar.strategy,
635+ note: buildStrategies[avatar.strategy].role,
636+ planReasons: avatar.planReasons,
637+ source: classProgression.find(
638+ (record) => record.classId === avatar.activeClassId,
639+ )?.source,
640+ },
641+ ];
642+ for (const plan of plans) {
643+ roster.push({
644+ kind: "companion",
645+ name: plan.name,
646+ className: plan.className,
647+ effectiveLevel: plan.effectiveLevel,
648+ rank: plan.personalRank,
649+ weapons: plan.weapons,
650+ arts: plan.arts,
651+ skills: plan.skills,
652+ skillSlots: plan.skillSlots,
653+ strategy: plan.strategy,
654+ note: plan.note,
655+ planReasons: plan.planReasons,
656+ source: plan.source,
657+ });
658+ }
659+ return roster;
660+}
661+
662+function legacyTeamScore(roles: ContextualRoleVector, community: number) {
663+ const core = [roles.tank, roles.support, roles.damage];
664+ return {
665+ minimumCoreCoverage: Math.min(...core),
666+ coveredCoreAxes: core.filter((value) => value > 0).length,
667+ cappedCoreDepth: core.reduce(
668+ (total, value) => total + Math.min(2, value),
669+ 0,
670+ ),
671+ communityFit: community,
672+ control: roles.control,
673+ total: roles.tank + roles.support + roles.damage + roles.control,
674+ };
675+}
676+
677+function variantLabel(
678+ variant: RecommendationVariant,
679+): readonly [string, string] {
680+ if (variant === "offensive")
681+ return ["Offensive alternative", "Alternative offensive"];
682+ if (variant === "safe") return ["Safe alternative", "Alternative sûre"];
683+ return ["Recommended party", "Équipe recommandée"];
684+}
685+
686+function variantReasons(
687+ variant: RecommendationVariant,
688+ score: ContextualTeamScore,
689+ lang: Lang,
690+ archetype: "tank" | "damage" | "support" | "hybrid",
691+): string[] {
692+ if (variant === "offensive") {
693+ return [
694+ translated(
695+ lang,
696+ `Prioritizes damage and TP tempo while retaining viable role coverage (offense ${score.offense}/10)`,
697+ `Privilégie dégâts et tempo TP tout en gardant une couverture viable (offense ${score.offense}/10)`,
698+ ),
699+ ];
700+ }
701+ if (variant === "safe") {
702+ return [
703+ translated(
704+ lang,
705+ `Prioritizes sustain and defensive tolerance (safety ${score.safety}/10)`,
706+ `Privilégie sustain et tolérance défensive (sécurité ${score.safety}/10)`,
707+ ),
708+ ];
709+ }
710+ return [
711+ translated(
712+ lang,
713+ `Balances companions around Cross's ${archetype} profile (balance ${score.balance}/10)`,
714+ `Équilibre les compagnons autour du profil ${archetype} de Cross (équilibre ${score.balance}/10)`,
715+ ),
716+ ];
717+}
718+
719+export function recommendContextualTeams(
720+ request: ContextualRecommendationRequest,
721+): ContextualRecommendationResult {
722+ const lang = request.lang ?? "en";
723+ const level = clamp(request.avatarLevel, 1, 99);
724+ const violations: string[] = [];
725+ const manual = new Set<string>();
726+ for (const name of request.slots) {
727+ if (!name) continue;
728+ const companion = companionByName.get(name);
729+ if (!companion) violations.push(`Unknown companion: ${name}`);
730+ else if (companion.startLevel > level)
731+ violations.push(`${name} requires level ${companion.startLevel}`);
732+ if (manual.has(name)) violations.push(`Duplicate companion: ${name}`);
733+ manual.add(name);
734+ }
735+ if (violations.length > 0) {
736+ return {
737+ kind: "infeasible",
738+ violations,
739+ relaxations: [
740+ translated(
741+ lang,
742+ "Raise the squad level or clear the invalid manual slot",
743+ "Augmenter le niveau ou corriger le slot manuel",
744+ ),
745+ ],
746+ };
747+ }
748+
749+ const eligible = sortedCompanions.filter(
750+ (companion) => companion.startLevel <= level && !manual.has(companion.name),
751+ );
752+ const autoSlots = request.slots.filter((name) => !name).length;
753+ if (eligible.length < autoSlots) {
754+ return {
755+ kind: "infeasible",
756+ violations: [
757+ translated(
758+ lang,
759+ "Not enough level-eligible companions to fill the party",
760+ "Pas assez de compagnons éligibles pour remplir l'équipe",
761+ ),
762+ ],
763+ relaxations: [
764+ translated(
765+ lang,
766+ "Raise the squad level to unlock more companions",
767+ "Augmenter le niveau d'équipe pour débloquer plus de compagnons",
768+ ),
769+ ],
770+ };
771+ }
772+ const requestedWeapons = request.weaponOverrides;
773+ if (requestedWeapons?.ranged || requestedWeapons?.melee) {
774+ const legalPairs = legalAvatarWeaponPairs(request.activeClassId, request.classRanks);
775+ const legalOverride = legalPairs.some((pair) =>
776+ (!requestedWeapons.ranged || pair.ranged === requestedWeapons.ranged)
777+ && (!requestedWeapons.melee || pair.melee === requestedWeapons.melee));
778+ if (!legalOverride) {
779+ return {
780+ kind: "infeasible",
781+ violations: [translated(
782+ lang,
783+ "Selected weapon override is not legal for the active class and mastered final classes",
784+ "La sélection d'armes n'est pas légale pour la classe active et les classes finales maîtrisées",
785+ )],
786+ relaxations: [translated(
787+ lang,
788+ "Clear the weapon override or master the corresponding final class at rank 10",
789+ "Retirer la sélection d'armes ou maîtriser la classe finale correspondante au rang 10",
790+ )],
791+ };
792+ }
793+ }
794+
795+ const avatar = planContextualAvatar({
796+ activeClassId: request.activeClassId,
797+ classRanks: request.classRanks,
798+ weaponOverrides: request.weaponOverrides,
799+ level,
800+ lang,
801+ assumeSpecialUnlocks: request.assumeSpecialUnlocks,
802+ });
803+ const crossRoles = avatarRoles(avatar);
804+ const archetype = strategyArchetype[avatar.strategy];
805+ const planCache = new Map<string, CompanionPlan>();
806+ const profileCache = new Map<string, CompanionContextEvaluation>();
807+ const planFor = (name: string, slotIndex: number): CompanionPlan => {
808+ const key = `${slotIndex}:${name}`;
809+ let plan = planCache.get(key);
810+ if (!plan) {
811+ plan = planCompanionForMode(
812+ name,
813+ level,
814+ request.personalRanks[slotIndex],
815+ request.combatMode,
816+ lang,
817+ );
818+ planCache.set(key, plan);
819+ }
820+ return plan;
821+ };
822+ const profileFor = (
823+ name: string,
824+ slotIndex: number,
825+ ): CompanionContextEvaluation => {
826+ const key = `${slotIndex}:${name}`;
827+ let profile = profileCache.get(key);
828+ if (!profile) {
829+ profile = evaluateCompanionContext(
830+ name,
831+ level,
832+ request.personalRanks[slotIndex],
833+ request.combatMode,
834+ );
835+ profileCache.set(key, profile);
836+ }
837+ return profile;
838+ };
839+
840+ const assignments: [string, string, string][] = [];
841+ const current: string[] = [];
842+ const used = new Set<string>();
843+ const enumerate = (index: number): void => {
844+ if (index === 3) {
845+ assignments.push([...current] as [string, string, string]);
846+ return;
847+ }
848+ const manualName = request.slots[index];
849+ const choices = manualName
850+ ? [manualName]
851+ : eligible.map((companion) => companion.name);
852+ for (const name of choices) {
853+ if (used.has(name)) continue;
854+ used.add(name);
855+ current.push(name);
856+ enumerate(index + 1);
857+ current.pop();
858+ used.delete(name);
859+ }
860+ };
861+ enumerate(0);
862+
863+ const evaluateFor = (
864+ names: [string, string, string],
865+ variant: RecommendationVariant,
866+ ): EvaluatedCandidate => {
867+ const plans = names.map((name, index) => planFor(name, index)) as [
868+ CompanionPlan,
869+ CompanionPlan,
870+ CompanionPlan,
871+ ];
872+ const profiles = names.map((name, index) => profileFor(name, index));
873+ const roles = profiles.reduce(
874+ (total, profile) => addRoles(total, profile.roles),
875+ crossRoles,
876+ );
877+ const progression =
878+ profiles.reduce((total, profile) => total + profile.progression, 0) /
879+ profiles.length;
880+ const community =
881+ names.reduce(
882+ (total, name) => total + (companionCommunityPrior[name] ?? 0.5),
883+ 0,
884+ ) / names.length;
885+ const score = scoreCandidate(
886+ roles,
887+ archetype,
888+ progression,
889+ community,
890+ request.combatMode,
891+ request.hasSkellLicense,
892+ variant,
893+ );
894+ const slots = names.map((name, index) => ({
895+ name,
896+ effectiveLevel: level,
897+ provenance: request.slots[index]
898+ ? ("manual" as const)
899+ : ("auto" as const),
900+ })) as [ResolvedTeamSlot, ResolvedTeamSlot, ResolvedTeamSlot];
901+ const coverage = {
902+ tank: round(roles.tank),
903+ support: round(roles.support),
904+ damage: round(roles.damage),
905+ control: round(roles.control),
906+ };
907+ const team: TeamResolution = {
908+ names,
909+ slots,
910+ coverage,
911+ score: legacyTeamScore(roles, community),
912+ mode: teamMode(request.slots),
913+ };
914+ return {
915+ names,
916+ plans,
917+ roles,
918+ score,
919+ team,
920+ roster: buildRoster(avatar, level, plans),
921+ };
922+ };
923+
924+ const selected: ContextualTeamRecommendation[] = [];
925+ const usedTeams = new Set<string>();
926+ const missingAlternatives: RecommendationVariant[] = [];
927+ for (const variant of ["primary", "offensive", "safe"] as const) {
928+ const candidates = assignments
929+ .map((names) => evaluateFor(names, variant))
930+ .sort(
931+ (left, right) =>
932+ right.score.total - left.score.total ||
933+ left.names.join("|").localeCompare(right.names.join("|"), "en"),
934+ );
935+ const candidate = candidates.find(
936+ (entry) => !usedTeams.has(teamKey(entry.names)),
937+ );
938+ if (!candidate) {
939+ missingAlternatives.push(variant);
940+ continue;
941+ }
942+ usedTeams.add(teamKey(candidate.names));
943+ selected.push({
944+ variant,
945+ label: variantLabel(variant),
946+ team: candidate.team,
947+ roster: candidate.roster,
948+ score: candidate.score,
949+ roles: candidate.roles,
950+ reasons: variantReasons(variant, candidate.score, lang, archetype),
951+ confidence: "medium",
952+ evidence: [
953+ evidenceCatalog.battleArts,
954+ evidenceCatalog.skills,
955+ evidenceCatalog.overdrive,
956+ evidenceCatalog.communityTeams,
957+ ],
958+ });
959+ }
960+
961+ return selected.length === 0
962+ ? {
963+ kind: "infeasible",
964+ violations: [
965+ translated(
966+ lang,
967+ "No valid non-duplicate party assignment",
968+ "Aucune affectation d'équipe valide sans doublon",
969+ ),
970+ ],
971+ relaxations: [
972+ translated(
973+ lang,
974+ "Clear a manual slot or raise the squad level",
975+ "Libérer un slot manuel ou augmenter le niveau",
976+ ),
977+ ],
978+ }
979+ : { kind: "success", recommendations: selected, missingAlternatives };
980+}
981+
982+export function recommendContextualSkellLoadout(
983+ request: ContextualSkellLoadoutRequest,
984+): ContextualSkellLoadout {
985+ const lang = request.lang ?? "en";
986+ const textIndex = lang === "fr" ? 1 : 0;
987+ const base = recommendSkellLoadout(request.strategy, request.frameLevel);
988+ const archetype =
989+ frameArchetypes.find((entry) => request.frame.includes(entry.key)) ??
990+ frameArchetypes.find((entry) => entry.key === "Urban")!;
991+ const fixedKit = fixedSkellKits.find((kit) =>
992+ kit.matches.some((match) => request.frame.includes(match)),
993+ );
994+ const modeNote =
995+ request.combatMode === "skell"
996+ ? translated(
997+ lang,
998+ "Optimized as the active combat package",
999+ "Optimisé comme package de combat principal",
1000+ )
1001+ : translated(
1002+ lang,
1003+ "Balanced as an accessible secondary package",
1004+ "Équilibré comme package secondaire accessible",
1005+ );
1006+ const packageNotes = fixedKit
1007+ ? fixedKit.notes.map((note) => note[textIndex])
1008+ : [
1009+ translated(
1010+ lang,
1011+ `${base.weapons.length} available mount families selected without inventing owned drops`,
1012+ `${base.weapons.length} familles de montures disponibles sélectionnées sans inventer de drops possédés`,
1013+ ),
1014+ translated(
1015+ lang,
1016+ "Treat frame association as a preference; replace a mount when encounter utility outweighs attribute focus",
1017+ "Traiter l'association au frame comme une préférence ; remplacer une monture si l'utilité de rencontre prime sur l'attribut",
1018+ ),
1019+ ];
1020+ return {
1021+ weapons: fixedKit?.weapons ?? base.weapons,
1022+ augments: fixedKit?.augments ?? base.augments,
1023+ archetype: archetype.label[textIndex],
1024+ reason: `${archetype.reason[textIndex]} ${modeNote}. Overdrive: ${fixedKit?.overdrive ?? archetype.overdrive}.`,
1025+ fixedKit: Boolean(fixedKit),
1026+ overdrive: fixedKit?.overdrive ?? archetype.overdrive,
1027+ fuelGpPlan: archetype.fuelGpPlan[textIndex],
1028+ packageNotes,
1029+ evidence: fixedKit
1030+ ? [evidenceCatalog.skell, evidenceCatalog.skellWeapons, fixedKit.evidence]
1031+ : [evidenceCatalog.skell, evidenceCatalog.skellWeapons],
1032+ };
1033+}
modified src/lib/engine.test.ts +62 -0
@@ -7,6 +7,7 @@ import {
77 classRanksForActiveClass,
88 editClassRank,
99 normalizeClassRanks,
10+ legalAvatarWeaponPairs,
1011 planAvatar,
1112 planCompanion,
1213 recommendSkellAlternatives,
@@ -119,6 +120,67 @@ test("mastered off-branch classes change legal Avatar recommendations", () => {
119120 assert.ok(expanded.skills.some((skill) => skill.name === "Combo Gunner"));
120121 });
121122
123+test("active native weapons unlock immediately and mastered final classes unlock mixed pairs", () => {
124+ const activeProgression = editClassRank(
125+ classRanksForActiveClass("galactic-knight", {}),
126+ "galactic-knight",
127+ 1,
128+ );
129+ const nativePairs = legalAvatarWeaponPairs("galactic-knight", activeProgression);
130+ assert.deepEqual(nativePairs, [{ ranged: "Psycho Launchers", melee: "Photon Saber" }]);
131+
132+ const partialJaguar = editClassRank(activeProgression, "full-metal-jaguar", 9);
133+ assert.equal(
134+ legalAvatarWeaponPairs("galactic-knight", partialJaguar).some(
135+ (pair) => pair.ranged === "Dual Guns" || pair.melee === "Dual Swords",
136+ ),
137+ false,
138+ );
139+
140+ const masteredJaguar = editClassRank(activeProgression, "full-metal-jaguar", 10);
141+ const mixedPairs = legalAvatarWeaponPairs("galactic-knight", masteredJaguar);
142+ assert.ok(mixedPairs.some(
143+ (pair) => pair.ranged === "Dual Guns" && pair.melee === "Photon Saber",
144+ ));
145+
146+ const mixedPlan = planAvatar(
147+ "galactic-knight",
148+ masteredJaguar,
149+ undefined,
150+ "en",
151+ 60,
152+ { ranged: "Dual Guns", melee: "Photon Saber" },
153+ );
154+ assert.deepEqual(mixedPlan.weapons, {
155+ ranged: "Dual Guns",
156+ melee: "Photon Saber",
157+ });
158+ assert.ok(mixedPlan.arts.every(
159+ (art) => art.weapon === "Dual Guns" || art.weapon === "Photon Saber",
160+ ));
161+
162+ const rangedOnly = planAvatar(
163+ "galactic-knight",
164+ masteredJaguar,
165+ undefined,
166+ "en",
167+ 60,
168+ { ranged: "Dual Guns" },
169+ );
170+ assert.equal(rangedOnly.weapons.ranged, "Dual Guns");
171+ assert.throws(
172+ () => planAvatar(
173+ "galactic-knight",
174+ activeProgression,
175+ undefined,
176+ "en",
177+ 60,
178+ { ranged: "Dual Guns" },
179+ ),
180+ /not legal/i,
181+ );
182+});
183+
122184 test("AUTO team selection is deterministic and guide-informed after coverage", () => {
123185 const result = resolveTeam(["", "", ""], "duelist", 30);
124186
@@ -7,6 +7,7 @@ import {
7 classRanksForActiveClass,7 classRanksForActiveClass,
8 editClassRank,8 editClassRank,
9 normalizeClassRanks,9 normalizeClassRanks,
10+ legalAvatarWeaponPairs,
10 planAvatar,11 planAvatar,
11 planCompanion,12 planCompanion,
12 recommendSkellAlternatives,13 recommendSkellAlternatives,
@@ -119,6 +120,67 @@ test("mastered off-branch classes change legal Avatar recommendations", () => {
119 assert.ok(expanded.skills.some((skill) => skill.name === "Combo Gunner"));120 assert.ok(expanded.skills.some((skill) => skill.name === "Combo Gunner"));
120 });121 });
121 122
123+test("active native weapons unlock immediately and mastered final classes unlock mixed pairs", () => {
124+ const activeProgression = editClassRank(
125+ classRanksForActiveClass("galactic-knight", {}),
126+ "galactic-knight",
127+ 1,
128+ );
129+ const nativePairs = legalAvatarWeaponPairs("galactic-knight", activeProgression);
130+ assert.deepEqual(nativePairs, [{ ranged: "Psycho Launchers", melee: "Photon Saber" }]);
131+
132+ const partialJaguar = editClassRank(activeProgression, "full-metal-jaguar", 9);
133+ assert.equal(
134+ legalAvatarWeaponPairs("galactic-knight", partialJaguar).some(
135+ (pair) => pair.ranged === "Dual Guns" || pair.melee === "Dual Swords",
136+ ),
137+ false,
138+ );
139+
140+ const masteredJaguar = editClassRank(activeProgression, "full-metal-jaguar", 10);
141+ const mixedPairs = legalAvatarWeaponPairs("galactic-knight", masteredJaguar);
142+ assert.ok(mixedPairs.some(
143+ (pair) => pair.ranged === "Dual Guns" && pair.melee === "Photon Saber",
144+ ));
145+
146+ const mixedPlan = planAvatar(
147+ "galactic-knight",
148+ masteredJaguar,
149+ undefined,
150+ "en",
151+ 60,
152+ { ranged: "Dual Guns", melee: "Photon Saber" },
153+ );
154+ assert.deepEqual(mixedPlan.weapons, {
155+ ranged: "Dual Guns",
156+ melee: "Photon Saber",
157+ });
158+ assert.ok(mixedPlan.arts.every(
159+ (art) => art.weapon === "Dual Guns" || art.weapon === "Photon Saber",
160+ ));
161+
162+ const rangedOnly = planAvatar(
163+ "galactic-knight",
164+ masteredJaguar,
165+ undefined,
166+ "en",
167+ 60,
168+ { ranged: "Dual Guns" },
169+ );
170+ assert.equal(rangedOnly.weapons.ranged, "Dual Guns");
171+ assert.throws(
172+ () => planAvatar(
173+ "galactic-knight",
174+ activeProgression,
175+ undefined,
176+ "en",
177+ 60,
178+ { ranged: "Dual Guns" },
179+ ),
180+ /not legal/i,
181+ );
182+});
183+
122 test("AUTO team selection is deterministic and guide-informed after coverage", () => {184 test("AUTO team selection is deterministic and guide-informed after coverage", () => {
123 const result = resolveTeam(["", "", ""], "duelist", 30);185 const result = resolveTeam(["", "", ""], "duelist", 30);
124 186
modified src/lib/engine.ts +28 -1
@@ -31,6 +31,7 @@ import type {
3131 TeamResolution,
3232 TeamScore,
3333 WeaponPair,
34+ WeaponOverrides,
3435 } from "@/src/lib/types";
3536
3637 const classById = new Map(classes.map((entry) => [entry.id, entry]));
@@ -290,6 +291,15 @@ function legalWeaponPairs(activeClass: ClassEntry, classRanks: Record<string, nu
290291 return pairs;
291292 }
292293
294+export function legalAvatarWeaponPairs(
295+ activeClassId: string,
296+ classRanks: ClassRanks,
297+): WeaponPair[] {
298+ const activeClass = classById.get(activeClassId);
299+ if (!activeClass) throw new Error(`Unknown active class: ${activeClassId}`);
300+ return legalWeaponPairs(activeClass, normalizeClassRanks(classRanks));
301+}
302+
293303 function pairScore(
294304 pair: WeaponPair,
295305 nativePair: WeaponPair,
@@ -411,6 +421,7 @@ export function planAvatar(
411421 strategy: StrategyKey = classStrategy[activeClassId],
412422 lang: Lang = "en",
413423 level = 99,
424+ weaponOverrides: WeaponOverrides = {},
414425 ): AvatarPlan {
415426 const activeClass = classById.get(activeClassId);
416427 const progression = classProgressionById.get(activeClassId);
@@ -422,8 +433,17 @@ export function planAvatar(
422433 const learned = learnedClassFacts(normalizedClassRanks);
423434 const strategyData = buildStrategies[strategy];
424435 const pairs = legalWeaponPairs(activeClass, normalizedClassRanks);
436+ const constrainedPairs = pairs.filter((pair) =>
437+ (!weaponOverrides.ranged || pair.ranged === weaponOverrides.ranged)
438+ && (!weaponOverrides.melee || pair.melee === weaponOverrides.melee));
439+ if (constrainedPairs.length === 0) {
440+ const requested = [weaponOverrides.ranged, weaponOverrides.melee]
441+ .filter(Boolean)
442+ .join(" + ");
443+ throw new Error(`Weapon override is not legal for ${activeClassId}: ${requested}`);
444+ }
425445 const nativePair = { ranged: activeClass.ranged, melee: activeClass.melee };
426- const rankedPairs = pairs
446+ const rankedPairs = constrainedPairs
427447 .map((pair) => ({
428448 pair,
429449 score: pairScore(pair, nativePair, learned.arts, strategyData.preferredArts),
@@ -474,6 +494,13 @@ export function planAvatar(
474494 `Accès aux armes interclasses de rang 4 maîtrisées : ${masteredTierFour.join(", ")}`,
475495 )
476496 : translated(lang, "No mastered tier-4 cross-class weapons", "Aucune arme interclasse de rang 4 maîtrisée"),
497+ ...(weaponOverrides.ranged || weaponOverrides.melee
498+ ? [translated(
499+ lang,
500+ `Manual weapon constraint: ${weaponOverrides.ranged ?? "AUTO"} + ${weaponOverrides.melee ?? "AUTO"}`,
501+ `Contrainte d'armes manuelle : ${weaponOverrides.ranged ?? "AUTO"} + ${weaponOverrides.melee ?? "AUTO"}`,
502+ )]
503+ : []),
477504 ],
478505 };
479506 }
@@ -31,6 +31,7 @@ import type {
31 TeamResolution,31 TeamResolution,
32 TeamScore,32 TeamScore,
33 WeaponPair,33 WeaponPair,
34+ WeaponOverrides,
34 } from "@/src/lib/types";35 } from "@/src/lib/types";
35 36
36 const classById = new Map(classes.map((entry) => [entry.id, entry]));37 const classById = new Map(classes.map((entry) => [entry.id, entry]));
@@ -290,6 +291,15 @@ function legalWeaponPairs(activeClass: ClassEntry, classRanks: Record<string, nu
290 return pairs;291 return pairs;
291 }292 }
292 293
294+export function legalAvatarWeaponPairs(
295+ activeClassId: string,
296+ classRanks: ClassRanks,
297+): WeaponPair[] {
298+ const activeClass = classById.get(activeClassId);
299+ if (!activeClass) throw new Error(`Unknown active class: ${activeClassId}`);
300+ return legalWeaponPairs(activeClass, normalizeClassRanks(classRanks));
301+}
302+
293 function pairScore(303 function pairScore(
294 pair: WeaponPair,304 pair: WeaponPair,
295 nativePair: WeaponPair,305 nativePair: WeaponPair,
@@ -411,6 +421,7 @@ export function planAvatar(
411 strategy: StrategyKey = classStrategy[activeClassId],421 strategy: StrategyKey = classStrategy[activeClassId],
412 lang: Lang = "en",422 lang: Lang = "en",
413 level = 99,423 level = 99,
424+ weaponOverrides: WeaponOverrides = {},
414 ): AvatarPlan {425 ): AvatarPlan {
415 const activeClass = classById.get(activeClassId);426 const activeClass = classById.get(activeClassId);
416 const progression = classProgressionById.get(activeClassId);427 const progression = classProgressionById.get(activeClassId);
@@ -422,8 +433,17 @@ export function planAvatar(
422 const learned = learnedClassFacts(normalizedClassRanks);433 const learned = learnedClassFacts(normalizedClassRanks);
423 const strategyData = buildStrategies[strategy];434 const strategyData = buildStrategies[strategy];
424 const pairs = legalWeaponPairs(activeClass, normalizedClassRanks);435 const pairs = legalWeaponPairs(activeClass, normalizedClassRanks);
436+ const constrainedPairs = pairs.filter((pair) =>
437+ (!weaponOverrides.ranged || pair.ranged === weaponOverrides.ranged)
438+ && (!weaponOverrides.melee || pair.melee === weaponOverrides.melee));
439+ if (constrainedPairs.length === 0) {
440+ const requested = [weaponOverrides.ranged, weaponOverrides.melee]
441+ .filter(Boolean)
442+ .join(" + ");
443+ throw new Error(`Weapon override is not legal for ${activeClassId}: ${requested}`);
444+ }
425 const nativePair = { ranged: activeClass.ranged, melee: activeClass.melee };445 const nativePair = { ranged: activeClass.ranged, melee: activeClass.melee };
426- const rankedPairs = pairs446+ const rankedPairs = constrainedPairs
427 .map((pair) => ({447 .map((pair) => ({
428 pair,448 pair,
429 score: pairScore(pair, nativePair, learned.arts, strategyData.preferredArts),449 score: pairScore(pair, nativePair, learned.arts, strategyData.preferredArts),
@@ -474,6 +494,13 @@ export function planAvatar(
474 `Accès aux armes interclasses de rang 4 maîtrisées : ${masteredTierFour.join(", ")}`,494 `Accès aux armes interclasses de rang 4 maîtrisées : ${masteredTierFour.join(", ")}`,
475 )495 )
476 : translated(lang, "No mastered tier-4 cross-class weapons", "Aucune arme interclasse de rang 4 maîtrisée"),496 : translated(lang, "No mastered tier-4 cross-class weapons", "Aucune arme interclasse de rang 4 maîtrisée"),
497+ ...(weaponOverrides.ranged || weaponOverrides.melee
498+ ? [translated(
499+ lang,
500+ `Manual weapon constraint: ${weaponOverrides.ranged ?? "AUTO"} + ${weaponOverrides.melee ?? "AUTO"}`,
501+ `Contrainte d'armes manuelle : ${weaponOverrides.ranged ?? "AUTO"} + ${weaponOverrides.melee ?? "AUTO"}`,
502+ )]
503+ : []),
477 ],504 ],
478 };505 };
479 }506 }
modified src/lib/types.ts +127 -0
@@ -91,6 +91,12 @@ export interface UnlockSource {
9191 citation: string;
9292 }
9393
94+export interface AbilityFallback {
95+ name: string;
96+ reason: string;
97+ source: UnlockSource;
98+}
99+
94100 export interface ArtSelection {
95101 name: string;
96102 weapon?: string;
@@ -98,6 +104,8 @@ export interface ArtSelection {
98104 recommendedLevel: number;
99105 source: UnlockSource;
100106 reason: string;
107+ specialUnlock?: boolean;
108+ fallback?: AbilityFallback;
101109 }
102110
103111 export interface SkillSelection {
@@ -106,6 +114,8 @@ export interface SkillSelection {
106114 recommendedLevel: number;
107115 source: UnlockSource;
108116 reason: string;
117+ specialUnlock?: boolean;
118+ fallback?: AbilityFallback;
109119 }
110120
111121 export interface WeaponPair {
@@ -113,6 +123,11 @@ export interface WeaponPair {
113123 melee: string;
114124 }
115125
126+export interface WeaponOverrides {
127+ ranged?: string;
128+ melee?: string;
129+}
130+
116131 export interface AvatarPlan {
117132 activeClassId: string;
118133 activeClassName: string;
@@ -208,3 +223,115 @@ export interface SkellLoadout {
208223 augments: readonly string[];
209224 }
210225
226+export type CombatMode = "general" | "skell";
227+export type RecommendationVariant = "primary" | "offensive" | "safe";
228+export type ConfidenceLevel = "high" | "medium" | "low" | "unknown";
229+
230+export interface EvidenceRef {
231+ id: string;
232+ label: string;
233+ url: string;
234+ tier: 1 | 2 | 3 | 4 | 5;
235+ edition: "DE" | "XCX" | "both";
236+}
237+
238+export interface ContextualRoleVector {
239+ tank: number;
240+ support: number;
241+ damage: number;
242+ control: number;
243+ sustain: number;
244+ tp: number;
245+ topple: number;
246+ skell: number;
247+}
248+
249+export interface ContextualTeamScore {
250+ balance: number;
251+ offense: number;
252+ safety: number;
253+ support: number;
254+ synergy: number;
255+ progression: number;
256+ community: number;
257+ skell: number;
258+ total: number;
259+}
260+
261+export interface CompanionContextEvaluation {
262+ name: string;
263+ personalRank: number;
264+ progression: number;
265+ roles: ContextualRoleVector;
266+ confidence: ConfidenceLevel;
267+ evidence: readonly EvidenceRef[];
268+}
269+
270+export interface ContextualAvatarRequest {
271+ activeClassId: string;
272+ classRanks: ClassRanks;
273+ weaponOverrides?: WeaponOverrides;
274+ level: number;
275+ lang?: Lang;
276+ assumeSpecialUnlocks: boolean;
277+}
278+
279+export interface ContextualRecommendationRequest {
280+ slots: readonly [string, string, string];
281+ activeClassId: string;
282+ avatarLevel: number;
283+ classRanks: ClassRanks;
284+ weaponOverrides?: WeaponOverrides;
285+ personalRanks: readonly [number, number, number];
286+ combatMode: CombatMode;
287+ hasSkellLicense: boolean;
288+ postgameFrames: boolean;
289+ assumeSpecialUnlocks: boolean;
290+ lang?: Lang;
291+}
292+
293+export interface ContextualTeamRecommendation {
294+ variant: RecommendationVariant;
295+ label: LocalizedText;
296+ team: TeamResolution;
297+ roster: readonly RosterMember[];
298+ score: ContextualTeamScore;
299+ roles: ContextualRoleVector;
300+ reasons: readonly string[];
301+ confidence: ConfidenceLevel;
302+ evidence: readonly EvidenceRef[];
303+}
304+
305+export interface ContextualRecommendationSuccess {
306+ kind: "success";
307+ recommendations: readonly ContextualTeamRecommendation[];
308+ missingAlternatives: readonly RecommendationVariant[];
309+}
310+
311+export interface ContextualRecommendationInfeasible {
312+ kind: "infeasible";
313+ violations: readonly string[];
314+ relaxations: readonly string[];
315+}
316+
317+export type ContextualRecommendationResult =
318+ | ContextualRecommendationSuccess
319+ | ContextualRecommendationInfeasible;
320+
321+export interface ContextualSkellLoadoutRequest {
322+ strategy: StrategyKey;
323+ frame: string;
324+ frameLevel: number;
325+ combatMode: CombatMode;
326+ lang?: Lang;
327+}
328+
329+export interface ContextualSkellLoadout extends SkellLoadout {
330+ archetype: string;
331+ reason: string;
332+ fixedKit: boolean;
333+ overdrive: string;
334+ fuelGpPlan: string;
335+ packageNotes: readonly string[];
336+ evidence: readonly EvidenceRef[];
337+}
@@ -91,6 +91,12 @@ export interface UnlockSource {
91 citation: string;91 citation: string;
92 }92 }
93 93
94+export interface AbilityFallback {
95+ name: string;
96+ reason: string;
97+ source: UnlockSource;
98+}
99+
94 export interface ArtSelection {100 export interface ArtSelection {
95 name: string;101 name: string;
96 weapon?: string;102 weapon?: string;
@@ -98,6 +104,8 @@ export interface ArtSelection {
98 recommendedLevel: number;104 recommendedLevel: number;
99 source: UnlockSource;105 source: UnlockSource;
100 reason: string;106 reason: string;
107+ specialUnlock?: boolean;
108+ fallback?: AbilityFallback;
101 }109 }
102 110
103 export interface SkillSelection {111 export interface SkillSelection {
@@ -106,6 +114,8 @@ export interface SkillSelection {
106 recommendedLevel: number;114 recommendedLevel: number;
107 source: UnlockSource;115 source: UnlockSource;
108 reason: string;116 reason: string;
117+ specialUnlock?: boolean;
118+ fallback?: AbilityFallback;
109 }119 }
110 120
111 export interface WeaponPair {121 export interface WeaponPair {
@@ -113,6 +123,11 @@ export interface WeaponPair {
113 melee: string;123 melee: string;
114 }124 }
115 125
126+export interface WeaponOverrides {
127+ ranged?: string;
128+ melee?: string;
129+}
130+
116 export interface AvatarPlan {131 export interface AvatarPlan {
117 activeClassId: string;132 activeClassId: string;
118 activeClassName: string;133 activeClassName: string;
@@ -208,3 +223,115 @@ export interface SkellLoadout {
208 augments: readonly string[];223 augments: readonly string[];
209 }224 }
210 225
226+export type CombatMode = "general" | "skell";
227+export type RecommendationVariant = "primary" | "offensive" | "safe";
228+export type ConfidenceLevel = "high" | "medium" | "low" | "unknown";
229+
230+export interface EvidenceRef {
231+ id: string;
232+ label: string;
233+ url: string;
234+ tier: 1 | 2 | 3 | 4 | 5;
235+ edition: "DE" | "XCX" | "both";
236+}
237+
238+export interface ContextualRoleVector {
239+ tank: number;
240+ support: number;
241+ damage: number;
242+ control: number;
243+ sustain: number;
244+ tp: number;
245+ topple: number;
246+ skell: number;
247+}
248+
249+export interface ContextualTeamScore {
250+ balance: number;
251+ offense: number;
252+ safety: number;
253+ support: number;
254+ synergy: number;
255+ progression: number;
256+ community: number;
257+ skell: number;
258+ total: number;
259+}
260+
261+export interface CompanionContextEvaluation {
262+ name: string;
263+ personalRank: number;
264+ progression: number;
265+ roles: ContextualRoleVector;
266+ confidence: ConfidenceLevel;
267+ evidence: readonly EvidenceRef[];
268+}
269+
270+export interface ContextualAvatarRequest {
271+ activeClassId: string;
272+ classRanks: ClassRanks;
273+ weaponOverrides?: WeaponOverrides;
274+ level: number;
275+ lang?: Lang;
276+ assumeSpecialUnlocks: boolean;
277+}
278+
279+export interface ContextualRecommendationRequest {
280+ slots: readonly [string, string, string];
281+ activeClassId: string;
282+ avatarLevel: number;
283+ classRanks: ClassRanks;
284+ weaponOverrides?: WeaponOverrides;
285+ personalRanks: readonly [number, number, number];
286+ combatMode: CombatMode;
287+ hasSkellLicense: boolean;
288+ postgameFrames: boolean;
289+ assumeSpecialUnlocks: boolean;
290+ lang?: Lang;
291+}
292+
293+export interface ContextualTeamRecommendation {
294+ variant: RecommendationVariant;
295+ label: LocalizedText;
296+ team: TeamResolution;
297+ roster: readonly RosterMember[];
298+ score: ContextualTeamScore;
299+ roles: ContextualRoleVector;
300+ reasons: readonly string[];
301+ confidence: ConfidenceLevel;
302+ evidence: readonly EvidenceRef[];
303+}
304+
305+export interface ContextualRecommendationSuccess {
306+ kind: "success";
307+ recommendations: readonly ContextualTeamRecommendation[];
308+ missingAlternatives: readonly RecommendationVariant[];
309+}
310+
311+export interface ContextualRecommendationInfeasible {
312+ kind: "infeasible";
313+ violations: readonly string[];
314+ relaxations: readonly string[];
315+}
316+
317+export type ContextualRecommendationResult =
318+ | ContextualRecommendationSuccess
319+ | ContextualRecommendationInfeasible;
320+
321+export interface ContextualSkellLoadoutRequest {
322+ strategy: StrategyKey;
323+ frame: string;
324+ frameLevel: number;
325+ combatMode: CombatMode;
326+ lang?: Lang;
327+}
328+
329+export interface ContextualSkellLoadout extends SkellLoadout {
330+ archetype: string;
331+ reason: string;
332+ fixedKit: boolean;
333+ overdrive: string;
334+ fuelGpPlan: string;
335+ packageNotes: readonly string[];
336+ evidence: readonly EvidenceRef[];
337+}