Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src-tauri/crates/olm_core/src/domain/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ pub struct Team {
#[serde(default, alias = "starting_xi_ids")]
pub active_lineup_ids: Vec<String>,

/// Per-role substitute player IDs (index 0..4 matching active_lineup_ids role order:
/// TOP, JUNGLE, MID, ADC, SUPPORT). An empty string means no sub is assigned for that role.
/// When the starter has critically low condition (< 30 %), the match engine will
/// automatically promote the sub into the active lineup for that match.
#[serde(default)]
pub role_sub_ids: Vec<String>,

#[serde(default)]
pub team_roles: TeamRoles,

Expand Down Expand Up @@ -1357,6 +1364,7 @@ impl Team {
secondary: "#ffffff".to_string(),
},
active_lineup_ids: Vec::new(),
role_sub_ids: Vec::new(),
team_roles: TeamRoles::default(),
form: Vec::new(),
history: Vec::new(),
Expand Down
28 changes: 28 additions & 0 deletions src-tauri/src/commands/squad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,34 @@ fn apply_active_lineup(game: &mut Game, team_id: &str, player_ids: Vec<String>)
}
}

/// Assign per-role substitutes for the active lineup.
///
/// `sub_ids` must be a Vec of 5 strings (one per role in the order TOP, JUNGLE,
/// MID, ADC, SUPPORT), where each entry is either a valid player ID or empty.
#[tauri::command]
pub fn set_role_subs(
state: State<'_, StateManager>,
sub_ids: Vec<String>,
) -> Result<Game, String> {
info!("[cmd] set_role_subs: {} subs", sub_ids.len());
let mut game = state
.get_game(|g| g.clone())
.ok_or("No active game session".to_string())?;

let team_id = game
.manager
.team_id
.clone()
.ok_or("No team assigned".to_string())?;

if let Some(team) = game.teams.iter_mut().find(|t| t.id == team_id) {
team.role_sub_ids = sub_ids;
}

state.set_game(game.clone());
Ok(game)
}

#[tauri::command]
pub fn set_draft_strategy(
state: State<'_, StateManager>,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ pub fn run() {
delegate_renewals,
preview_renewal_financial_impact,
set_active_lineup,
set_role_subs,
set_starting_xi,
set_draft_strategy,
set_lol_tactics,
Expand Down
2 changes: 2 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ export interface TeamData {
sponsorship?: SponsorshipData | null;
/** Preferred LoL terminology. Serialized by current saves/API responses. */
active_lineup_ids?: string[];
/** Per-role substitute player IDs (indexed same as active_lineup_ids). */
role_sub_ids?: string[];
/** @deprecated Compatibility for older saves/API payloads. Use active_lineup_ids. */
starting_xi_ids?: string[];
team_roles?: TeamRolesData;
Expand Down
81 changes: 79 additions & 2 deletions src/ui-v2/dashboard/tabs/SquadTabV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ShoppingCart,
User,
Loader2,
RefreshCw,
} from "lucide-react";

import type { GameStateData, PlayerSelectionOptions } from "@/store/gameStore";
Expand Down Expand Up @@ -77,6 +78,21 @@ export function SquadTabV2({
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [search, setSearch] = useState("");
const [savingSlot, setSavingSlot] = useState<string | null>(null);
const [swappingSlot, setSwappingSlot] = useState<number | null>(null);

const roleSubIds: string[] = myTeam.role_sub_ids ?? [];

const handleSetRoleSub = useCallback(async (roleIndex: number, playerId: string) => {
const newSubs = [...roleSubIds];
while (newSubs.length < 5) newSubs.push("");
newSubs[roleIndex] = playerId;
try {
const updated = await invoke<GameStateData>("set_role_subs", { subIds: newSubs });
onGameUpdate(updated);
} catch (err) {
console.error("[SquadTab] Failed to set role sub:", err);
}
}, [roleSubIds, onGameUpdate]);

const handleToggleTransfer = useCallback(async (playerId: string) => {
try {
Expand Down Expand Up @@ -308,8 +324,10 @@ export function SquadTabV2({
const morale = player.morale;
const annualWage = player.wage;

return (
<div key={slot.role} onClick={(e) => { if (!(e.target as HTMLElement).closest("select,button")) onSelectPlayer(player.id); }} className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/30">
return (
<div key={slot.role} onClick={(e) => { if (!(e.target as HTMLElement).closest("select,button")) onSelectPlayer(player.id); }} className={cn("flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/30",
condition < 30 && "border-l-2 border-l-red-500/60 bg-red-500/5"
)}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50">
<img src={ROLE_ICON_URLS[slot.role]} alt={roleLabel} className="size-4 object-contain opacity-80" />
</div>
Expand All @@ -326,6 +344,11 @@ export function SquadTabV2({
<AlertTriangle className="size-4" />
</span>
)}
{condition < 30 && (
<span className="shrink-0 text-red-400" title={t("squad.lowConditionTooltip", { defaultValue: "Energía crítica — necesita descanso" })}>
<AlertTriangle className="size-4" />
</span>
)}
</div>
<div className="flex items-center gap-2">
<select
Expand Down Expand Up @@ -404,6 +427,24 @@ export function SquadTabV2({
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">/año</p>
</div>
<div className="flex items-center gap-1">
{condition < 30 && (
<button type="button"
onClick={(e) => {
e.stopPropagation();
// Prefer the designated sub for this role; fall back to best OVR bench player
const designatedSubId = roleSubIds[slot.index];
const designatedSub = designatedSubId ? benchPlayers.find((bp) => bp.id === designatedSubId) : null;
const replacement = designatedSub ?? benchPlayers
.filter((bp) => resolvePlayerLolRole(bp) === slot.role)
.sort((a, b) => calculateLolOvr(b) - calculateLolOvr(a))[0];
if (replacement) handleSlotChange(slot.index, replacement.id);
}}
title={t("squad.quickSwap", { defaultValue: "Cambiar por suplente" })}
className="flex size-7 items-center justify-center rounded-md border border-amber-500/40 bg-amber-500/10 text-amber-400 transition-colors hover:bg-amber-500/20"
>
<RefreshCw className="size-3.5" />
</button>
)}
<button type="button" onClick={(e) => { e.stopPropagation(); handleToggleTransfer(player.id); }}
title={player.transfer_listed ? t("squad.removeFromTransferList", { defaultValue: "Remove from transfers list" }) : t("squad.addToTransferList", { defaultValue: "Add to transfers list" })}
className={cn("flex size-7 items-center justify-center rounded-md border transition-colors",
Expand All @@ -422,6 +463,42 @@ export function SquadTabV2({
<ChevronRight className="size-4" />
</button>
</div>
{/* Sub slot for this role */}
<div className="mt-1 ml-11 flex items-center gap-2">
{(() => {
const subId = roleSubIds[slot.index];
const sub = subId ? roster.find((p) => p.id === subId) : null;
return (
<>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground/60 w-6">SUB</span>
<select
value={subId ?? ""}
onChange={(e) => handleSetRoleSub(slot.index, e.target.value)}
className="max-w-[160px] rounded-md border border-border/60 bg-muted/40 pl-1.5 pr-6 py-0.5 text-[11px] text-foreground"
>
<option value="">{t("squad.noSub", { defaultValue: "Sin suplente" })}</option>
{benchPlayers
.filter((bp) => !activeLineupIds.includes(bp.id))
.sort((a, b) => {
const aMatch = resolvePlayerLolRole(a) === slot.role ? 0 : 1;
const bMatch = resolvePlayerLolRole(b) === slot.role ? 0 : 1;
return aMatch - bMatch || calculateLolOvr(b) - calculateLolOvr(a);
})
.map((bp) => (
<option key={bp.id} value={bp.id}>
{bp.match_name} — {resolvePlayerLolRole(bp)} ({calculateLolOvr(bp)})
</option>
))}
</select>
{sub && sub.condition < 30 && (
<span className="text-[10px] text-red-400" title={t("squad.subLowCondition", { defaultValue: "Suplente también tiene la energía baja" })}>
<AlertTriangle className="size-3" />
</span>
)}
</>
);
})()}
</div>
</div>
);
})}
Expand Down
92 changes: 64 additions & 28 deletions src/ui-v2/dashboard/tabs/YouthTabV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
Info,
Loader2,
Search,
ShoppingCart,
Sparkles,
Star,
TrendingUp,
Users,
} from "lucide-react";

import { invoke } from "@tauri-apps/api/core";
import type { GameStateData, PlayerData, AcademyAcquisitionOptionData } from "@/store/gameStore";
import { findAcademyTeamForParent, getTeamAcademyRoster } from "@/store/academySelectors";
import {
Expand Down Expand Up @@ -92,6 +94,7 @@ export function YouthTabV2({ gameState, onSelectPlayer, onSelectTeam, onGameUpda
);

const [promotingPlayerId, setPromotingPlayerId] = useState<string | null>(null);
const [transferListingPlayerId, setTransferListingPlayerId] = useState<string | null>(null);
const [acquisitionOptions, setAcquisitionOptions] = useState<AcademyAcquisitionOptionData[]>([]);
const [acquisitionBlockedReason, setAcquisitionBlockedReason] = useState<string | null>(null);
const [acquisitionLoading, setAcquisitionLoading] = useState(false);
Expand Down Expand Up @@ -621,35 +624,68 @@ export function YouthTabV2({ gameState, onSelectPlayer, onSelectTeam, onGameUpda
</div>
</TableCell>
<TableCell className="text-center">
<button
type="button"
disabled={promotingPlayerId === player.id}
onClick={async (e) => {
e.stopPropagation();
try {
setPromotingPlayerId(player.id);
const updated = await promoteAcademyPlayer(player.id);
onGameUpdate?.(updated);
} finally {
setPromotingPlayerId(null);
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={transferListingPlayerId === player.id}
onClick={async (e) => {
e.stopPropagation();
try {
setTransferListingPlayerId(player.id);
const updated = await invoke<GameStateData>("toggle_transfer_list", { playerId: player.id });
onGameUpdate?.(updated);
} finally {
setTransferListingPlayerId(null);
}
}}
className={cn(
"flex size-7 items-center justify-center rounded-md border transition-colors",
player.transfer_listed
? "border-red-500/30 bg-red-500/10 text-red-400"
: "border-border text-muted-foreground/50 hover:border-red-500/30 hover:text-red-400",
)}
title={
player.transfer_listed
? t("youthAcademy.removeFromTransferList", { defaultValue: "Quitar de transferibles" })
: t("youthAcademy.addToTransferList", { defaultValue: "Añadir a transferibles" })
}
}}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-heading uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed",
promotingPlayerId === player.id
? "border-border bg-muted/30 text-muted-foreground"
: "border-primary/30 bg-primary/10 text-primary hover:bg-primary/20",
)}
>
{promotingPlayerId === player.id ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t("youthAcademy.promoting")}
</span>
) : (
t("youthAcademy.promote")
)}
</button>
>
{transferListingPlayerId === player.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ShoppingCart className="size-3.5" />
)}
</button>
<button
type="button"
disabled={promotingPlayerId === player.id}
onClick={async (e) => {
e.stopPropagation();
try {
setPromotingPlayerId(player.id);
const updated = await promoteAcademyPlayer(player.id);
onGameUpdate?.(updated);
} finally {
setPromotingPlayerId(null);
}
}}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-heading uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed",
promotingPlayerId === player.id
? "border-border bg-muted/30 text-muted-foreground"
: "border-primary/30 bg-primary/10 text-primary hover:bg-primary/20",
)}
>
{promotingPlayerId === player.id ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t("youthAcademy.promoting")}
</span>
) : (
t("youthAcademy.promote")
)}
</button>
</div>
</TableCell>
</TableRow>
);
Expand Down