feat: add progression-aware contextual team and weapon recommendations
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 | ||
| 43 | 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 | 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 | 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 | 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 | 48 | .rank-hint { margin:10px 0 6px; font-size:13px; color:#6f9193; letter-spacing:.1em; line-height:1.5; } |
| 48 | 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 | 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 | 72 | .lvl-badge:hover,.lvl-badge:focus-visible{border-color:var(--cyan);background:rgba(150,239,240,.08)} |
| 72 | 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 | 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 | 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 | 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 | 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 | 85 | @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 @@ | ||
| 11 | 11 | "dev": "next dev", |
| 12 | 12 | "build": "next build", |
| 13 | 13 | "lint": "eslint .", |
| 14 | - "test": "tsx --test src/lib/engine.test.ts", | |
| 14 | + "test": "tsx --test src/lib/*.test.ts", | |
| 15 | 15 | "typecheck": "tsc --noEmit" |
| 16 | 16 | }, |
| 17 | 17 | "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"; | ||
| 3 | 3 | import { buildStrategies } from "@/src/data/builds"; |
| 4 | 4 | import { localizedGameUiText } from "@/src/data/i18n"; |
| 5 | 5 | import type { Copy } from "@/src/data/i18n"; |
| 6 | +import { recommendContextualSkellLoadout } from "@/src/lib/contextualEngine"; | |
| 6 | 7 | import { |
| 7 | 8 | localized, |
| 8 | 9 | recommendSkellAlternatives, |
| 9 | 10 | recommendSkellFrame, |
| 10 | - recommendSkellLoadout, | |
| 11 | 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 | 14 | interface BuildCardProps { |
| 15 | 15 | index: number; |
| 16 | 16 | member: RosterMember; |
| 17 | 17 | lang: Lang; |
| 18 | 18 | t: Copy; |
| 19 | + combatMode: CombatMode; | |
| 19 | 20 | hasSkellLicense: boolean; |
| 20 | 21 | postgameFrames: boolean; |
| 21 | 22 | } |
| @@ -25,6 +26,7 @@ export default function BuildCard({ | ||
| 25 | 26 | member, |
| 26 | 27 | lang, |
| 27 | 28 | t, |
| 29 | + combatMode, | |
| 28 | 30 | hasSkellLicense, |
| 29 | 31 | postgameFrames, |
| 30 | 32 | }: BuildCardProps) { |
| @@ -37,7 +39,15 @@ export default function BuildCard({ | ||
| 37 | 39 | postgameFrames, |
| 38 | 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 | 51 | const alternatives = skell |
| 42 | 52 | ? recommendSkellAlternatives({ |
| 43 | 53 | strategy: member.strategy, |
| @@ -61,14 +71,15 @@ export default function BuildCard({ | ||
| 61 | 71 | <p className="weapon-lock">{member.weapons.ranged} + {member.weapons.melee} · {localized(member.note, lang)}</p> |
| 62 | 72 | {member.arts.length > 0 ? <ol> |
| 63 | 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 | 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 | 77 | <small>{art.weapon ?? `${member.weapons.ranged} + ${member.weapons.melee}`}</small> |
| 68 | 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 | 81 | {t.levelShort} {art.recommendedLevel} |
| 71 | - </a> | |
| 82 | + </span> | |
| 72 | 83 | </li> |
| 73 | 84 | ))} |
| 74 | 85 | </ol> : <p className="empty-loadout">{t.noLearnedArts}</p>} |
| @@ -78,12 +89,12 @@ export default function BuildCard({ | ||
| 78 | 89 | <h4><span>S</span>{t.skills} · {member.skills.length}/{member.skillSlots}</h4> |
| 79 | 90 | {member.skills.length > 0 ? <div className="chip-list"> |
| 80 | 91 | {member.skills.map((skill) => ( |
| 81 | - <span key={`${skill.source.id}-${skill.name}`}> | |
| 92 | + <span key={skill.name}> | |
| 82 | 93 | <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> | |
| 87 | 98 | </span> |
| 88 | 99 | ))} |
| 89 | 100 | </div> : <p className="empty-loadout">{t.noLearnedSkills}</p>} |
| @@ -106,15 +117,20 @@ export default function BuildCard({ | ||
| 106 | 117 | <h4><span>K</span>{t.skellSection}</h4> |
| 107 | 118 | {skell && loadout ? <> |
| 108 | 119 | <div className="skell-frame"> |
| 109 | - <small>{t.skellFrame}</small> | |
| 120 | + <small>{t.skellFrame} · {loadout.archetype}</small> | |
| 110 | 121 | <strong>{skell.frame}</strong> |
| 111 | 122 | <span>{localized(strategy.skellRole, lang)}</span> |
| 112 | 123 | </div> |
| 113 | 124 | <dl className="skell-facts"> |
| 114 | 125 | <div><dt>{t.requiredPilotLevel}</dt><dd>{t.levelShort} {skell.requiredLevel}</dd></div> |
| 115 | 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 | 130 | </dl> |
| 131 | + <ul className="skell-package-notes"> | |
| 132 | + {loadout.packageNotes.map((note) => <li key={note}>{note}</li>)} | |
| 133 | + </ul> | |
| 118 | 134 | {alternatives.length > 0 && <div className="skell-alts"> |
| 119 | 135 | <small>{t.skellAlternatives}</small> |
| 120 | 136 | {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 = skell | 51 | 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"; | ||
| 6 | 6 | /** Class select grouped by route so each option shows which branch it belongs to. */ |
| 7 | 7 | const drifter = classes.find((entry) => entry.tier === 1)!; |
| 8 | 8 | 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 | + })), | |
| 17 | 19 | ]; |
| 18 | 20 | |
| 19 | 21 | 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; | |
| 34 | 42 | } |
| 35 | 43 | |
| 36 | 44 | 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, | |
| 39 | 65 | }: 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> | |
| 54 | 109 | |
| 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> | |
| 75 | 144 | |
| 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 | + ); | |
| 108 | 277 | } |
| @@ -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 | - ...classes | 10 | + ...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 @@ | ||
| 1 | 1 | import { useState } from "react"; |
| 2 | -import type { Copy } from "@/src/data/i18n"; | |
| 3 | 2 | import BuildCard from "@/src/components/BuildCard"; |
| 3 | +import type { Copy } from "@/src/data/i18n"; | |
| 4 | 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 | 13 | interface ResultsSectionProps { |
| 8 | 14 | t: Copy; |
| 9 | 15 | lang: Lang; |
| 10 | 16 | avatarLevel: number; |
| 11 | 17 | route: ClassEntry[]; |
| 12 | - roster: RosterMember[]; | |
| 13 | - resolvedTeam: TeamResolution; | |
| 18 | + result: ContextualRecommendationResult; | |
| 19 | + combatMode: CombatMode; | |
| 14 | 20 | hasSkellLicense: boolean; |
| 15 | 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 | 30 | export default function ResultsSection({ |
| 20 | 31 | t, |
| 21 | 32 | lang, |
| 22 | 33 | avatarLevel, |
| 23 | 34 | route, |
| 24 | - roster, | |
| 25 | - resolvedTeam, | |
| 35 | + result, | |
| 36 | + combatMode, | |
| 26 | 37 | hasSkellLicense, |
| 27 | 38 | postgameFrames, |
| 28 | 39 | }: ResultsSectionProps) { |
| 29 | - const avatar = roster[0]!; | |
| 40 | + const [activeRecommendation, setActiveRecommendation] = useState(0); | |
| 30 | 41 | 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 | |
| 34 | 77 | .map((slot) => slot.name || t.noEligibleOperator) |
| 35 | 78 | .join(" · "); |
| 36 | - const selectionDetails = resolvedTeam.slots | |
| 79 | + const selectionDetails = recommendation.team.slots | |
| 37 | 80 | .map((slot, index) => |
| 38 | 81 | `0${index + 1} ${slot.provenance === "manual" ? t.manualPick : t.autoPick}: ${slot.name || t.noEligibleOperator}`) |
| 39 | 82 | .join(" · "); |
| @@ -42,10 +85,28 @@ export default function ResultsSection({ | ||
| 42 | 85 | <section className="results is-ready" id="results"> |
| 43 | 86 | <div className="section-heading results-heading"> |
| 44 | 87 | <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> | |
| 47 | 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 | 111 | <div className="squad-summary"> |
| 51 | 112 | <div> |
| @@ -54,19 +115,33 @@ export default function ResultsSection({ | ||
| 54 | 115 | <span>RANK {avatar.rank} · {avatar.weapons.ranged} + {avatar.weapons.melee}</span> |
| 55 | 116 | </div> |
| 56 | 117 | <div> |
| 57 | - <small>{t.teamMode} · {resolvedTeam.mode.toUpperCase()}</small> | |
| 118 | + <small>{t.teamMode} · {recommendation.team.mode.toUpperCase()} · {combatModeLabel.toUpperCase()}</small> | |
| 58 | 119 | <strong>{resolvedNames}</strong> |
| 59 | 120 | <span>{selectionDetails}</span> |
| 60 | 121 | </div> |
| 61 | 122 | <div className="coverage-summary"> |
| 62 | 123 | <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> | |
| 65 | 126 | </div> |
| 66 | 127 | </div> |
| 67 | 128 | |
| 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) => ( | |
| 70 | 145 | <button |
| 71 | 146 | key={`${member.kind}-${member.name}`} |
| 72 | 147 | role="tab" |
| @@ -89,6 +164,7 @@ export default function ResultsSection({ | ||
| 89 | 164 | member={currentMember} |
| 90 | 165 | lang={lang} |
| 91 | 166 | t={t} |
| 167 | + combatMode={combatMode} | |
| 92 | 168 | hasSkellLicense={hasSkellLicense} |
| 93 | 169 | postgameFrames={postgameFrames} |
| 94 | 170 | /> |
| @@ -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.slots | 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 | ||
| 34 | .map((slot) => slot.name || t.noEligibleOperator) | 77 | .map((slot) => slot.name || t.noEligibleOperator) |
| 35 | .join(" · "); | 78 | .join(" · "); |
| 36 | - const selectionDetails = resolvedTeam.slots | 79 | + 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 | <button | 145 | <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 @@ | ||
| 1 | 1 | import { companions } from "@/src/data/companions"; |
| 2 | 2 | import type { Copy } from "@/src/data/i18n"; |
| 3 | +import type { CombatMode } from "@/src/lib/types"; | |
| 3 | 4 | |
| 4 | 5 | /** Companions listed alphabetically in slot selects. */ |
| 5 | 6 | const sortedCompanions = [...companions].sort((left, right) => |
| @@ -11,12 +12,16 @@ interface SquadPanelProps { | ||
| 11 | 12 | resolvedTeamNames: readonly [string, string, string]; |
| 12 | 13 | effectiveLevel: number; |
| 13 | 14 | personalRanks: readonly [number, number, number]; |
| 15 | + combatMode: CombatMode; | |
| 14 | 16 | hasSkellLicense: boolean; |
| 15 | 17 | postgameFrames: boolean; |
| 18 | + assumeSpecialUnlocks: boolean; | |
| 16 | 19 | onUpdateMember: (index: number, value: string) => void; |
| 17 | 20 | onUpdateRank: (index: number, value: number) => void; |
| 21 | + onCombatModeChange: (value: CombatMode) => void; | |
| 18 | 22 | onHasSkellLicenseChange: (value: boolean) => void; |
| 19 | 23 | onPostgameFramesChange: (value: boolean) => void; |
| 24 | + onAssumeSpecialUnlocksChange: (value: boolean) => void; | |
| 20 | 25 | onViewResults: () => void; |
| 21 | 26 | } |
| 22 | 27 | |
| @@ -26,12 +31,16 @@ export default function SquadPanel({ | ||
| 26 | 31 | resolvedTeamNames, |
| 27 | 32 | effectiveLevel, |
| 28 | 33 | personalRanks, |
| 34 | + combatMode, | |
| 29 | 35 | hasSkellLicense, |
| 30 | 36 | postgameFrames, |
| 37 | + assumeSpecialUnlocks, | |
| 31 | 38 | onUpdateMember, |
| 32 | 39 | onUpdateRank, |
| 40 | + onCombatModeChange, | |
| 33 | 41 | onHasSkellLicenseChange, |
| 34 | 42 | onPostgameFramesChange, |
| 43 | + onAssumeSpecialUnlocksChange, | |
| 35 | 44 | onViewResults, |
| 36 | 45 | }: SquadPanelProps) { |
| 37 | 46 | return ( |
| @@ -70,10 +79,18 @@ export default function SquadPanel({ | ||
| 70 | 79 | })} |
| 71 | 80 | </div> |
| 72 | 81 | |
| 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> | |
| 75 | 91 | <label><input type="checkbox" checked={hasSkellLicense} onChange={(event) => onHasSkellLicenseChange(event.target.checked)} />{t.skellLicense}</label> |
| 76 | 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 | 94 | </fieldset> |
| 78 | 95 | |
| 79 | 96 | <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 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 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 | 4 | import ControlPanel from "@/src/components/ControlPanel"; |
| 16 | 5 | import ResultsSection from "@/src/components/ResultsSection"; |
| 17 | 6 | import SiteFooter from "@/src/components/SiteFooter"; |
| 18 | 7 | import SquadPanel from "@/src/components/SquadPanel"; |
| 19 | 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 | 21 | 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); | |
| 38 | 44 | |
| 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 ?? ["", "", ""]; | |
| 58 | 114 | |
| 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 | + } | |
| 63 | 121 | |
| 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 | + } | |
| 73 | 148 | |
| 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 | + } | |
| 83 | 159 | |
| 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 | + } | |
| 93 | 170 | |
| 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 | + } | |
| 97 | 180 | |
| 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} /> | |
| 102 | 185 | |
| 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> | |
| 109 | 195 | |
| 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> | |
| 146 | 242 | |
| 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 | + /> | |
| 157 | 253 | |
| 158 | - <SiteFooter t={t} /> | |
| 159 | - </main> | |
| 160 | - ); | |
| 254 | + <SiteFooter t={t} /> | |
| 255 | + </main> | |
| 256 | + ); | |
| 161 | 257 | } |
| @@ -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 | - <ControlPanel | 197 | + <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 | - <SquadPanel | 213 | + 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 | - <ResultsSection | 243 | + <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 { | ||
| 15 | 15 | rank: string; |
| 16 | 16 | hierarchy: string; |
| 17 | 17 | weapons: string; |
| 18 | + weaponLoadout: string; | |
| 19 | + rangedWeapon: string; | |
| 20 | + meleeWeapon: string; | |
| 21 | + autoWeapon: string; | |
| 22 | + weaponOverrideHint: string; | |
| 18 | 23 | allClassRanks: string; |
| 19 | 24 | builderHeading: string; |
| 20 | 25 | squad: string; |
| @@ -24,8 +29,28 @@ export interface Copy { | ||
| 24 | 29 | skellProgression: string; |
| 25 | 30 | skellLicense: string; |
| 26 | 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 | 49 | viewResults: string; |
| 50 | + resultsEyebrowGeneral: string; | |
| 51 | + resultsEyebrowSkell: string; | |
| 28 | 52 | resultsHeading: string; |
| 53 | + resultsHeadingSkell: string; | |
| 29 | 54 | activeRoute: string; |
| 30 | 55 | teamMode: string; |
| 31 | 56 | manualPick: string; |
| @@ -64,7 +89,7 @@ export const copy: Record<Lang, Copy> = { | ||
| 64 | 89 | status: "SYSTEM READY", |
| 65 | 90 | eyebrow: "BLADE // TACTICAL SYSTEM 07", |
| 66 | 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 | 93 | avatarLevel: "Cross level", |
| 69 | 94 | levelShort: "LVL", |
| 70 | 95 | personalRank: "Personal class rank", |
| @@ -74,6 +99,11 @@ export const copy: Record<Lang, Copy> = { | ||
| 74 | 99 | rank: "Active class rank", |
| 75 | 100 | hierarchy: "Class route", |
| 76 | 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 | 107 | allClassRanks: "All class ranks", |
| 78 | 108 | builderHeading: "Configure your deployment", |
| 79 | 109 | squad: "Squad roster", |
| @@ -83,8 +113,28 @@ export const copy: Record<Lang, Copy> = { | ||
| 83 | 113 | skellProgression: "Skell progression", |
| 84 | 114 | skellLicense: "Has Skell License", |
| 85 | 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 | 133 | viewResults: "View live recommendation", |
| 134 | + resultsEyebrowGeneral: "TACTICAL OUTPUT", | |
| 135 | + resultsEyebrowSkell: "SKELL TACTICAL OUTPUT", | |
| 87 | 136 | resultsHeading: "All-rounder recommendation", |
| 137 | + resultsHeadingSkell: "Skell combat recommendation", | |
| 88 | 138 | activeRoute: "ACTIVE ROUTE", |
| 89 | 139 | teamMode: "TEAM MODE", |
| 90 | 140 | manualPick: "MANUAL", |
| @@ -111,8 +161,8 @@ export const copy: Record<Lang, Copy> = { | ||
| 111 | 161 | skellNoLicense: "No frame is recommended without the Skell License.", |
| 112 | 162 | skellLowLevel: "No frame is recommended before pilot level 20.", |
| 113 | 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.", | |
| 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.", | |
| 116 | 166 | rankDown: "Decrease rank", |
| 117 | 167 | rankUp: "Increase rank", |
| 118 | 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 | 171 | status: "SYSTÈME PRÊT", |
| 122 | 172 | eyebrow: "BLADE // SYSTÈME TACTIQUE 07", |
| 123 | 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 | 175 | avatarLevel: "Niveau de Cross", |
| 126 | 176 | levelShort: "NIV", |
| 127 | 177 | personalRank: "Rang de classe personnel", |
| @@ -131,6 +181,11 @@ export const copy: Record<Lang, Copy> = { | ||
| 131 | 181 | rank: "Rang de la classe active", |
| 132 | 182 | hierarchy: "Filière de classe", |
| 133 | 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 | 189 | allClassRanks: "Rangs de toutes les classes", |
| 135 | 190 | builderHeading: "Configurez votre déploiement", |
| 136 | 191 | squad: "Composition de l’équipe", |
| @@ -140,8 +195,28 @@ export const copy: Record<Lang, Copy> = { | ||
| 140 | 195 | skellProgression: "Progression Skell", |
| 141 | 196 | skellLicense: "Licence Skell obtenue", |
| 142 | 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 | 215 | viewResults: "Voir la recommandation en direct", |
| 216 | + resultsEyebrowGeneral: "SORTIE TACTIQUE", | |
| 217 | + resultsEyebrowSkell: "SORTIE TACTIQUE SKELL", | |
| 144 | 218 | resultsHeading: "Recommandation polyvalente", |
| 219 | + resultsHeadingSkell: "Recommandation de combat Skell", | |
| 145 | 220 | activeRoute: "FILIÈRE ACTIVE", |
| 146 | 221 | teamMode: "MODE D’ÉQUIPE", |
| 147 | 222 | manualPick: "MANUEL", |
| @@ -168,8 +243,8 @@ export const copy: Record<Lang, Copy> = { | ||
| 168 | 243 | skellNoLicense: "Aucun châssis n’est recommandé sans la Licence Skell.", |
| 169 | 244 | skellLowLevel: "Aucun châssis n’est recommandé avant le niveau de pilote 20.", |
| 170 | 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.", | |
| 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.", | |
| 173 | 248 | rankDown: "Diminuer le rang", |
| 174 | 249 | rankUp: "Augmenter le rang", |
| 175 | 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 | 262 | "Complete Chapter 13 (Act 1)": "Terminer le chapitre 13 (acte 1)", |
| 188 | 263 | "Craftable following Rapid Misfire": "Fabricable après « Rapid Misfire »", |
| 189 | 264 | "Shop purchase / New LA craft": "Achat en boutique / fabrication à New LA", |
| 265 | + "Fixed equipment": "Équipement fixe", | |
| 266 | + spare: "auxiliaire", | |
| 190 | 267 | sidearm: "arme latérale", |
| 191 | 268 | back: "dos", |
| 192 | 269 | 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"; | ||
| 2 | 2 | |
| 3 | 3 | export interface SkellWeaponPlan { |
| 4 | 4 | name: string; |
| 5 | - slot: "sidearm" | "back" | "shoulder" | "arm"; | |
| 5 | + slot: "sidearm" | "back" | "shoulder" | "arm" | "spare"; | |
| 6 | 6 | attribute: string; |
| 7 | 7 | acquisition: string; |
| 8 | 8 | } |
| @@ -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 { | ||
| 7 | 7 | classRanksForActiveClass, |
| 8 | 8 | editClassRank, |
| 9 | 9 | normalizeClassRanks, |
| 10 | + legalAvatarWeaponPairs, | |
| 10 | 11 | planAvatar, |
| 11 | 12 | planCompanion, |
| 12 | 13 | recommendSkellAlternatives, |
| @@ -119,6 +120,67 @@ test("mastered off-branch classes change legal Avatar recommendations", () => { | ||
| 119 | 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 | 184 | test("AUTO team selection is deterministic and guide-informed after coverage", () => { |
| 123 | 185 | const result = resolveTeam(["", "", ""], "duelist", 30); |
| 124 | 186 | |
| @@ -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 { | ||
| 31 | 31 | TeamResolution, |
| 32 | 32 | TeamScore, |
| 33 | 33 | WeaponPair, |
| 34 | + WeaponOverrides, | |
| 34 | 35 | } from "@/src/lib/types"; |
| 35 | 36 | |
| 36 | 37 | const classById = new Map(classes.map((entry) => [entry.id, entry])); |
| @@ -290,6 +291,15 @@ function legalWeaponPairs(activeClass: ClassEntry, classRanks: Record<string, nu | ||
| 290 | 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 | 303 | function pairScore( |
| 294 | 304 | pair: WeaponPair, |
| 295 | 305 | nativePair: WeaponPair, |
| @@ -411,6 +421,7 @@ export function planAvatar( | ||
| 411 | 421 | strategy: StrategyKey = classStrategy[activeClassId], |
| 412 | 422 | lang: Lang = "en", |
| 413 | 423 | level = 99, |
| 424 | + weaponOverrides: WeaponOverrides = {}, | |
| 414 | 425 | ): AvatarPlan { |
| 415 | 426 | const activeClass = classById.get(activeClassId); |
| 416 | 427 | const progression = classProgressionById.get(activeClassId); |
| @@ -422,8 +433,17 @@ export function planAvatar( | ||
| 422 | 433 | const learned = learnedClassFacts(normalizedClassRanks); |
| 423 | 434 | const strategyData = buildStrategies[strategy]; |
| 424 | 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 | 445 | const nativePair = { ranged: activeClass.ranged, melee: activeClass.melee }; |
| 426 | - const rankedPairs = pairs | |
| 446 | + const rankedPairs = constrainedPairs | |
| 427 | 447 | .map((pair) => ({ |
| 428 | 448 | pair, |
| 429 | 449 | score: pairScore(pair, nativePair, learned.arts, strategyData.preferredArts), |
| @@ -474,6 +494,13 @@ export function planAvatar( | ||
| 474 | 494 | `Accès aux armes interclasses de rang 4 maîtrisées : ${masteredTierFour.join(", ")}`, |
| 475 | 495 | ) |
| 476 | 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 | } |
| @@ -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 = pairs | 446 | + 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 { | ||
| 91 | 91 | citation: string; |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | +export interface AbilityFallback { | |
| 95 | + name: string; | |
| 96 | + reason: string; | |
| 97 | + source: UnlockSource; | |
| 98 | +} | |
| 99 | + | |
| 94 | 100 | export interface ArtSelection { |
| 95 | 101 | name: string; |
| 96 | 102 | weapon?: string; |
| @@ -98,6 +104,8 @@ export interface ArtSelection { | ||
| 98 | 104 | recommendedLevel: number; |
| 99 | 105 | source: UnlockSource; |
| 100 | 106 | reason: string; |
| 107 | + specialUnlock?: boolean; | |
| 108 | + fallback?: AbilityFallback; | |
| 101 | 109 | } |
| 102 | 110 | |
| 103 | 111 | export interface SkillSelection { |
| @@ -106,6 +114,8 @@ export interface SkillSelection { | ||
| 106 | 114 | recommendedLevel: number; |
| 107 | 115 | source: UnlockSource; |
| 108 | 116 | reason: string; |
| 117 | + specialUnlock?: boolean; | |
| 118 | + fallback?: AbilityFallback; | |
| 109 | 119 | } |
| 110 | 120 | |
| 111 | 121 | export interface WeaponPair { |
| @@ -113,6 +123,11 @@ export interface WeaponPair { | ||
| 113 | 123 | melee: string; |
| 114 | 124 | } |
| 115 | 125 | |
| 126 | +export interface WeaponOverrides { | |
| 127 | + ranged?: string; | |
| 128 | + melee?: string; | |
| 129 | +} | |
| 130 | + | |
| 116 | 131 | export interface AvatarPlan { |
| 117 | 132 | activeClassId: string; |
| 118 | 133 | activeClassName: string; |
| @@ -208,3 +223,115 @@ export interface SkellLoadout { | ||
| 208 | 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 | +} | |
| @@ -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 | +} | ||