From 4ad45f3106fb087be5aad4b27099cb0d1b099872 Mon Sep 17 00:00:00 2001 From: TobiasTest22 Date: Thu, 2 Jul 2026 03:13:43 +0300 Subject: [PATCH 01/13] Map FM26 squad data and unify scouting UI --- README.md | 4 +- docs/FM26_MAPPING_STATUS.md | 43 ++ package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 44 +- src-tauri/Cargo.toml | 4 +- src-tauri/entity-maps/index.json | 7 +- src-tauri/src/connector.rs | 541 +++++++++++++++++--- src-tauri/src/lib.rs | 4 + src-tauri/src/player_face.rs | 159 ++++++ src-tauri/src/tactic_file.rs | 89 +++- src-tauri/tauri.conf.json | 2 +- src/app/globals.css | 69 +++ src/components/app-sidebar.tsx | 23 +- src/components/dashboard-screen.tsx | 8 +- src/components/favorited-players-screen.tsx | 2 +- src/components/glass-scout-app.tsx | 10 +- src/components/my-team-screen.tsx | 68 ++- src/components/player-face.tsx | 52 ++ src/components/player-profile-screen.tsx | 43 +- src/components/recruitment-screen.tsx | 198 +++---- src/components/startup-screen.tsx | 6 +- src/components/tactics-screen.tsx | 1 + src/components/topbar.tsx | 4 +- src/components/ui/button.tsx | 2 +- src/domain/adapters.ts | 10 + src/domain/distribution.ts | 2 +- 27 files changed, 1148 insertions(+), 253 deletions(-) create mode 100644 docs/FM26_MAPPING_STATUS.md create mode 100644 src-tauri/src/player_face.rs create mode 100644 src/components/player-face.tsx diff --git a/README.md b/README.md index 048e79c..00db5ec 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The desktop connector can currently: - keep every non-squad index record behind a visibility gate until club scout knowledge can be mapped safely; - validate signatures, object types, collection bounds and pointer chains before returning entities. -Version `0.1.4` supports FM26 `6000.0.52.8888375`, product `6000.0.52f1-fm26-05f1 (87a0370e9917)`, x64, executable SHA-256 `3653C97F9CCEC2BE28EDC4FAAE67304B5B6C26733F2F07DEA3E7C591D3B9FF73`. +Version `0.1.5` supports FM26 `6000.0.52.8888375`, product `6000.0.52f1-fm26-05f1 (87a0370e9917)`, x64, executable SHA-256 `3653C97F9CCEC2BE28EDC4FAAE67304B5B6C26733F2F07DEA3E7C591D3B9FF73`. Live-memory tactic reading is disabled and cannot block squad/player connection. The user may choose one `.fmf` tactic file through the native Windows file picker. GlassScout validates and copies that file into local app data, then reports whether its format can be decoded. The current FM26 FMF container is recognized but not yet fully decoded, so no formation, role, duty or tactic-fit result is guessed. @@ -37,7 +37,7 @@ GlassScout is live-game only. When the installed FM26 build has no verified enti ## Install the Windows test build -Download [GlassScout FM26 0.1.4 for Windows x64](https://github.com/TobiasTest22/GlassScout/releases/download/app-v0.1.4/GlassScout.FM26_0.1.4_x64-setup.exe). +Download [GlassScout FM26 0.1.5 for Windows x64](https://github.com/TobiasTest22/GlassScout/releases/download/app-v0.1.5/GlassScout.FM26_0.1.5_x64-setup.exe). The NSIS setup installs GlassScout locally and uses Tauri's WebView2 bootstrapper when the required Windows web runtime is missing. The prerelease is not code-signed yet, so Windows SmartScreen may ask for confirmation. diff --git a/docs/FM26_MAPPING_STATUS.md b/docs/FM26_MAPPING_STATUS.md new file mode 100644 index 0000000..e37f1d7 --- /dev/null +++ b/docs/FM26_MAPPING_STATUS.md @@ -0,0 +1,43 @@ +# FM26 live-data mapping status + +Validated against FM26 build `6000.0.52.8888375` (`6000.0.52f1-fm26-05f1`) and the active Madla IL save on 2 July 2026. + +## Mapped and enabled + +| Field | Source | Validation | +| --- | --- | --- | +| Player unique ID | Person entity UID | Matched the FM profile IDs for the managed squad | +| Name | Person name records | Matched FM squad and profile screens | +| Date of birth and age | Person date plus active-save date | Lars Sveingard resolves to 13 Aug 1995 and age 30, matching FM | +| Nationality | Person-to-nation relationship | Lars Sveingard resolves to Norway, matching FM | +| Position familiarity | 15-byte player position map | Corrected order includes ML and MR; matched FM positions | +| Visible attributes | 54-byte player attribute map | 1–100 storage converted to FM's displayed 1–20 values and checked against FM | +| Preferred foot | Left/right-foot storage | Exposed only as Left, Right or Both | +| Player portrait | FM unique ID | Resolves standard FM26 face/icon packs by UID; clean fallback when absent | + +Own-squad records are treated as fully known because FM exposes their player information to the manager. A visible-attribute role-evidence score may be calculated for them. It is not FM current ability or potential ability. + +## Deliberately blocked + +- Raw current ability and potential ability are not exposed or used. +- Dirtiness, consistency, important matches, injury proneness and versatility remain hidden. +- Wider-save player attributes remain hidden even though the entity records can be indexed. +- Wider-save players are not surfaced until the `GAME_SCOUTED_PERSON` / scout-manager relationship has been validated for this build. + +## Still unmapped + +| Field | Reason | +| --- | --- | +| Scout ranges, report confidence, last scouted and reliability | The scouting manager exists in memory, but its player-report collection and field semantics are not yet validated | +| Second nationality | The second-nation relationship is not yet identified | +| Form and match rating | Values are not stored directly in the player object and the match-stat relationship is not yet validated | +| Contract expiry, wage and squad status | Own-squad test data is amateur/N/A and no safely validated non-amateur relationship is available | +| Valuation and transfer value range | The current-build value relationship is not validated | +| Transfer/loan status and interest | The transfer and agent-interest relationships are not validated | +| Fitness/injury status | The condition/injury relationship is not validated; hidden injury proneness is intentionally blocked | + +Unknown fields must remain `null` and display as **Unknown**. + +## FMF tactic files + +The FM26 `.fmf` archive and its Zstandard index are detected and decoded safely. Archive entries such as `.tac`, `.img` and `details.aom` are identified. The current `.tac` payload is encrypted in the AFE container; formation, roles, duties and instructions therefore remain unavailable and are never guessed. diff --git a/package-lock.json b/package-lock.json index e4ca0aa..e7489a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "glassscout-fm26", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "glassscout-fm26", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/inter": "^5.2.8", diff --git a/package.json b/package.json index 3fffff5..b16e38c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glassscout-fm26", - "version": "0.1.4", + "version": "0.1.5", "private": true, "scripts": { "dev": "next dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cc76ad0..e95949e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -369,6 +369,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1281,8 +1283,9 @@ dependencies = [ [[package]] name = "glassscout-fm26" -version = "0.1.4" +version = "0.1.5" dependencies = [ + "base64 0.22.1", "serde", "serde_json", "sha2", @@ -1290,6 +1293,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-sql", + "zstd", ] [[package]] @@ -1834,6 +1838,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -5577,3 +5591,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 09ece78..f0396c6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "glassscout-fm26" -version = "0.1.4" +version = "0.1.5" description = "Read-only desktop recruitment workspace for Football Manager 2026" authors = ["GlassScout"] edition = "2021" @@ -13,9 +13,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tauri = { version = "2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-sql = { version = "2", features = ["sqlite"] } +zstd = "0.13" diff --git a/src-tauri/entity-maps/index.json b/src-tauri/entity-maps/index.json index f1095b6..d08284e 100644 --- a/src-tauri/entity-maps/index.json +++ b/src-tauri/entity-maps/index.json @@ -42,7 +42,12 @@ "clubNameOffset": 192, "entityUidOffset": 12, "playerPersonOffset": 648, - "playerPositionsOffset": 336 + "playerPositionsOffset": 336, + "playerAttributesOffset": 351, + "playerCurrentDateOffset": 512, + "personNationalityOffset": 104, + "nationNameOffset": 24, + "personBirthDateOffset": 136 } } ] diff --git a/src-tauri/src/connector.rs b/src-tauri/src/connector.rs index ac60679..14626e7 100644 --- a/src-tauri/src/connector.rs +++ b/src-tauri/src/connector.rs @@ -1,7 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use tauri::Emitter; use std::{ collections::{HashMap, HashSet}, fs::File, @@ -9,12 +8,70 @@ use std::{ path::Path, sync::{OnceLock, RwLock}, }; +use tauri::Emitter; const READ_ONLY_PROCESS_ACCESS: u32 = 0x1410; const MAX_STRING_BYTES: usize = 192; -const POSITION_NAMES: [&str; 13] = [ - "GK", "SW", "DL", "DC", "DR", "DM", "WBL", "MC", "WBR", "AML", "AMR", "AMC", "ST", +const POSITION_NAMES: [&str; 15] = [ + "GK", "SW", "DL", "DC", "DR", "DM", "ML", "MC", "MR", "AML", "AMC", "AMR", "ST", "WBL", "WBR", ]; +const PLAYER_ATTRIBUTE_NAMES: [&str; 54] = [ + "Crossing", + "Dribbling", + "Finishing", + "Heading", + "Long Shots", + "Marking", + "Off the Ball", + "Passing", + "Penalty Taking", + "Tackling", + "Vision", + "Handling", + "Aerial Reach", + "Command of Area", + "Communication", + "Kicking", + "Throwing", + "Anticipation", + "Decisions", + "One on Ones", + "Positioning", + "Reflexes", + "First Touch", + "Technique", + "Left Foot", + "Right Foot", + "Flair", + "Corners", + "Teamwork", + "Work Rate", + "Long Throws", + "Eccentricity", + "Rushing Out", + "Punching", + "Acceleration", + "Free Kick Taking", + "Strength", + "Stamina", + "Pace", + "Jumping Reach", + "Leadership", + "Dirtiness", + "Balance", + "Bravery", + "Consistency", + "Aggression", + "Agility", + "Important Matches", + "Injury Proneness", + "Versatility", + "Natural Fitness", + "Determination", + "Composure", + "Concentration", +]; +const HIDDEN_ATTRIBUTE_INDEXES: [usize; 5] = [41, 44, 47, 48, 49]; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -141,6 +198,11 @@ struct MapConstants { entity_uid_offset: u64, player_person_offset: u64, player_positions_offset: u64, + player_attributes_offset: u64, + player_current_date_offset: u64, + person_nationality_offset: u64, + nation_name_offset: u64, + person_birth_date_offset: u64, } #[derive(Default)] @@ -155,6 +217,7 @@ struct ExtractionDiagnostics { struct LiveData { managed_club_id: String, manager_name: String, + season: Option, clubs: Vec, players: Vec, database_players_indexed: u32, @@ -173,6 +236,12 @@ struct IndexedPlayerRecord { visibility_safe: bool, } +#[derive(Clone, Copy)] +struct FmDate { + year: u16, + day_of_year: u16, +} + #[derive(Default)] struct PlayerDatabaseIndex { process_id: u32, @@ -436,7 +505,7 @@ fn collect_snapshot( status.database_players_indexed = data.database_players_indexed; status.background_players_indexed = data.background_players_indexed; status.visible_players_loaded = data.players.len() as u32; - status.fully_scouted_players = 0; + status.fully_scouted_players = data.players.len() as u32; status.partial_scout_reports = 0; status.database_index_status = data.database_index_status; status.database_scope = data.database_scope; @@ -458,7 +527,7 @@ fn collect_snapshot( status, managed_club_id: Some(data.managed_club_id), manager_name: Some(data.manager_name), - season: None, + season: data.season, clubs: data.clubs, players: data.players, tactic: None, @@ -761,7 +830,51 @@ fn extract_live_data( positions.push(POSITION_NAMES[position].to_string()); } } - let calculated_position = positions.first().cloned(); + let calculated_position = position_bytes + .iter() + .enumerate() + .max_by_key(|(_, rating)| **rating) + .map(|(position, _)| POSITION_NAMES[position].to_string()); + let birth_date = read_fm_date(reader, person + profile.constants.person_birth_date_offset); + let current_date = read_fm_date( + reader, + raw_player + profile.constants.player_current_date_offset, + ); + let age = birth_date + .zip(current_date) + .and_then(|(birth, current)| calculate_age(birth, current)); + let date_of_birth = birth_date.and_then(format_fm_date); + let season = current_date.map(|date| { + let next_year = date.year.saturating_add(1); + format!("{}/{}", date.year, next_year % 100) + }); + let nationality = read_nationality(reader, person, profile); + let attribute_bytes = reader + .read_bytes( + raw_player + profile.constants.player_attributes_offset, + PLAYER_ATTRIBUTE_NAMES.len(), + ) + .ok_or_else(|| { + ExtractionFailure::new( + "player_attributes", + "A managed squad attribute record was not readable.", + ) + })?; + if attribute_bytes.iter().any(|value| *value > 100) { + return Err(ExtractionFailure::new( + "player_attributes", + "A managed squad attribute record failed its 1–100 storage bounds check.", + )); + } + let visible_attributes = visible_attribute_map(&attribute_bytes); + let preferred_foot = preferred_foot_label(attribute_bytes[24], attribute_bytes[25]); + let best_role = calculated_position + .as_deref() + .map(default_role_for_position); + let ability_score = calculated_position + .as_deref() + .and_then(|position| visible_ability_score(position, &visible_attributes)); + let (strengths, weaknesses) = attribute_evidence(&visible_attributes); let player_id = uid.to_string(); managed_player_ids.insert(player_id.clone()); managed_index_records.push(IndexedPlayerRecord { @@ -774,12 +887,16 @@ fn extract_live_data( players.push(json!({ "id": player_id, "name": name, - "age": null, - "nationality": null, + "_season": season, + "age": age, + "dateOfBirth": date_of_birth, + "nationality": nationality, + "secondNationality": null, "positions": positions, - "bestRole": null, + "bestRole": best_role, "currentAbility": null, "potentialAbility": null, + "abilityScore": ability_score, "form": null, "averageRating": null, "minutesPlayed": null, @@ -791,86 +908,95 @@ fn extract_live_data( "squadImportance": null, "developmentTrend": null, "tacticalFit": null, - "roleFit": null, - "strengths": [], - "weaknesses": [], + "roleFit": ability_score, + "preferredFoot": preferred_foot, + "strengths": strengths, + "weaknesses": weaknesses, "clubId": club_id, "transferInterest": null, "loanInterest": null, "transferAvailable": null, "loanAvailable": null, - "attributes": {}, + "attributes": visible_attributes, "per90": {}, - "scoutKnowledge": "partly_known", + "scoutKnowledge": "fully_known", + "scoutConfidence": 100, + "lastScoutedDate": null, + "reportReliability": null, "bestCalculatedPosition": calculated_position, "truePrice": null, "fairPriceRange": null, "valuationLabel": "unavailable", "valuationReasoning": ["FM26 valuation fields are not mapped for this exact build."], "retrainingSuggestion": null, - "roleReasoning": ["Only live positional familiarity is currently validated for this build."], + "roleReasoning": ["Role evidence uses only the managed player's visible FM26 attributes and positional familiarity."], "riskLevel": "unknown", "marketValueAmount": null })); } diagnostics.last_successful_read = Some("extract_managed_squad".to_string()); - let (database_players_indexed, background_players_indexed, database_index_status, database_scope) = - if include_database_index { - if let Some(progress) = progress { - progress("indexing_player_database"); - } - let seed_vtable = player_vtable.ok_or_else(|| { - ExtractionFailure::new( - "player_database", - "The live squad did not provide a player type signature.", - ) - })?; - match index_full_player_database( - reader, - profile, - process_id, - diagnostics.save_pointer.unwrap_or_default(), - seed_vtable, - &managed_player_ids, - managed_index_records, - ) { - Ok(indexed) => { - if let Some(progress) = progress { - progress("building_visibility_index"); - } - diagnostics.last_successful_read = - Some("index_full_player_database".to_string()); - ( - indexed.total, - indexed.background, - "ready", - "full-save-index", - ) + let ( + database_players_indexed, + background_players_indexed, + database_index_status, + database_scope, + ) = if include_database_index { + if let Some(progress) = progress { + progress("indexing_player_database"); + } + let seed_vtable = player_vtable.ok_or_else(|| { + ExtractionFailure::new( + "player_database", + "The live squad did not provide a player type signature.", + ) + })?; + match index_full_player_database( + reader, + profile, + process_id, + diagnostics.save_pointer.unwrap_or_default(), + seed_vtable, + &managed_player_ids, + managed_index_records, + ) { + Ok(indexed) => { + if let Some(progress) = progress { + progress("building_visibility_index"); } - Err(_) => ( - player_count as u32, - 0, - "partial", - "managed-squad", - ), + diagnostics.last_successful_read = Some("index_full_player_database".to_string()); + ( + indexed.total, + indexed.background, + "ready", + "full-save-index", + ) } - } else { - store_player_index( - process_id, - diagnostics.save_pointer.unwrap_or_default(), - managed_index_records, - ); - ( - player_count as u32, - 0, - "not_run", - "managed-squad", - ) - }; + Err(_) => (player_count as u32, 0, "partial", "managed-squad"), + } + } else { + store_player_index( + process_id, + diagnostics.save_pointer.unwrap_or_default(), + managed_index_records, + ); + (player_count as u32, 0, "not_run", "managed-squad") + }; + let season = players + .first() + .and_then(|player| player.get("_season")) + .and_then(Value::as_str) + .map(str::to_string); + for player in &mut players { + if let Some(object) = player.as_object_mut() { + object.remove("_season"); + } + } let mut warnings = vec![ - "Player names and positional familiarity are live; age, attributes, form, contract, value, wage and performance fields remain unavailable for this exact map.".to_string(), + "Managed-squad IDs, names, dates of birth, ages, nationality, positions, preferred foot and visible attributes are validated for this FM26 build. Hidden CA/PA is never read or scored.".to_string(), + "Form, match ratings, contract, wage, valuation, fitness and squad-status relationships are not yet validated for this build and remain Unknown.".to_string(), + "Own-squad players are fully known. Wider-save records remain hidden until FM26 scout-report visibility, ranges and confidence are validated.".to_string(), "Live-memory tactic reading is disabled. Tactic Evaluation uses only a user-selected local FMF file.".to_string(), "The FM26 shortlist collection is not mapped safely. GlassScout Favorites remains a local list resolved against live players.".to_string(), ]; @@ -893,6 +1019,7 @@ fn extract_live_data( Ok(LiveData { managed_club_id: club_uid.to_string(), manager_name, + season, clubs, players, database_players_indexed, @@ -910,11 +1037,7 @@ struct IndexSummary { } #[cfg(target_os = "windows")] -fn store_player_index( - process_id: u32, - save_pointer: u64, - records: Vec, -) { +fn store_player_index(process_id: u32, save_pointer: u64, records: Vec) { let records = records .into_iter() .map(|record| (record.id.clone(), record)) @@ -1038,6 +1161,227 @@ fn read_indexed_player_candidate( }) } +fn display_attribute(raw: u8) -> u8 { + ((raw.saturating_add(4)) / 5).clamp(1, 20) +} + +fn visible_attribute_map(raw: &[u8]) -> HashMap { + PLAYER_ATTRIBUTE_NAMES + .iter() + .enumerate() + .filter(|(index, _)| { + !HIDDEN_ATTRIBUTE_INDEXES.contains(index) && !matches!(*index, 24 | 25) + }) + .filter_map(|(index, name)| { + raw.get(index) + .copied() + .map(|value| ((*name).to_string(), display_attribute(value))) + }) + .collect() +} + +fn preferred_foot_label(left: u8, right: u8) -> &'static str { + let difference = i16::from(left) - i16::from(right); + if difference >= 20 { + "Left" + } else if difference <= -20 { + "Right" + } else { + "Both" + } +} + +fn default_role_for_position(position: &str) -> String { + match position { + "GK" => "Goalkeeper", + "SW" | "DC" => "Central Defender", + "DL" | "DR" | "WBL" | "WBR" => "Full-Back", + "DM" => "Defensive Midfielder", + "ML" | "MR" | "AML" | "AMR" => "Winger", + "MC" => "Central Midfielder", + "AMC" => "Attacking Midfielder", + "ST" => "Advanced Forward", + _ => "Natural Position", + } + .to_string() +} + +fn visible_ability_score(position: &str, attributes: &HashMap) -> Option { + let keys: &[&str] = match position { + "GK" => &[ + "Aerial Reach", + "Command of Area", + "Communication", + "Handling", + "One on Ones", + "Reflexes", + "Positioning", + "Decisions", + ], + "DC" | "SW" => &[ + "Marking", + "Tackling", + "Heading", + "Positioning", + "Anticipation", + "Decisions", + "Jumping Reach", + "Strength", + ], + "DL" | "DR" | "WBL" | "WBR" => &[ + "Marking", + "Tackling", + "Positioning", + "Crossing", + "Pace", + "Acceleration", + "Stamina", + "Work Rate", + ], + "DM" => &[ + "Tackling", + "Positioning", + "Anticipation", + "Decisions", + "Passing", + "Teamwork", + "Work Rate", + "Stamina", + ], + "ML" | "MR" | "AML" | "AMR" => &[ + "Crossing", + "Dribbling", + "First Touch", + "Off the Ball", + "Pace", + "Acceleration", + "Agility", + "Technique", + ], + "MC" | "AMC" => &[ + "Passing", + "Vision", + "First Touch", + "Technique", + "Decisions", + "Anticipation", + "Teamwork", + "Work Rate", + ], + "ST" => &[ + "Finishing", + "First Touch", + "Off the Ball", + "Anticipation", + "Composure", + "Acceleration", + "Pace", + "Technique", + ], + _ => &["Decisions", "Teamwork", "Work Rate", "Natural Fitness"], + }; + let values = keys + .iter() + .filter_map(|key| attributes.get(*key).copied()) + .map(u32::from) + .collect::>(); + (!values.is_empty()).then(|| { + let average = values.iter().sum::() as f32 / values.len() as f32; + ((average / 20.0) * 100.0).round().clamp(0.0, 100.0) as u8 + }) +} + +fn attribute_evidence(attributes: &HashMap) -> (Vec, Vec) { + let mut values = attributes + .iter() + .map(|(name, value)| (name.clone(), *value)) + .collect::>(); + values.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + let strengths = values + .iter() + .take(3) + .map(|(name, value)| format!("{name} {value}")) + .collect(); + let weaknesses = values + .iter() + .rev() + .take(3) + .map(|(name, value)| format!("{name} {value}")) + .collect(); + (strengths, weaknesses) +} + +fn is_leap_year(year: u16) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +fn month_day(date: FmDate) -> Option<(u8, u8)> { + let mut remaining = u32::from(date.day_of_year); + if remaining == 0 { + return None; + } + let month_lengths = [ + 31_u32, + if is_leap_year(date.year) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + for (index, length) in month_lengths.iter().enumerate() { + if remaining <= *length { + return Some(((index + 1) as u8, remaining as u8)); + } + remaining -= length; + } + None +} + +fn format_fm_date(date: FmDate) -> Option { + let (month, day) = month_day(date)?; + Some(format!("{:04}-{month:02}-{day:02}", date.year)) +} + +fn calculate_age(birth: FmDate, current: FmDate) -> Option { + if current.year < birth.year { + return None; + } + let birthday_passed = current.day_of_year >= birth.day_of_year; + let years = current.year - birth.year - u16::from(!birthday_passed); + u8::try_from(years).ok().filter(|age| *age <= 100) +} + +#[cfg(target_os = "windows")] +fn read_fm_date(reader: &mut ProcessReader, address: u64) -> Option { + let bytes = reader.read_bytes(address, 4)?; + let day_of_year = u16::from_le_bytes([bytes[0], bytes[1]]); + let year = u16::from_le_bytes([bytes[2], bytes[3]]); + let max_day = if is_leap_year(year) { 366 } else { 365 }; + (year >= 1900 && day_of_year > 0 && day_of_year <= max_day) + .then_some(FmDate { year, day_of_year }) +} + +#[cfg(target_os = "windows")] +fn read_nationality( + reader: &mut ProcessReader, + person: u64, + profile: &EntityMapProfile, +) -> Option { + let nation = reader.read_pointer(person + profile.constants.person_nationality_offset)?; + let name = (nation != 0) + .then(|| reader.read_pointer(nation + profile.constants.nation_name_offset)) + .flatten()?; + (name != 0) + .then(|| reader.read_length_prefixed_string(name)) + .flatten() +} + fn display_name( first_name: Option, second_name: Option, @@ -1651,8 +1995,21 @@ mod tests { assert!(player["potentialAbility"].is_null()); assert!(player["attributes"] .as_object() - .is_some_and(|value| value.is_empty())); + .is_some_and(|value| !value.is_empty())); + assert_eq!(player["scoutKnowledge"], "fully_known"); } + let lars = snapshot + .players + .iter() + .find(|player| player["id"] == "53179170") + .expect("Lars Sveingard"); + assert_eq!(lars["age"], 30); + assert_eq!(lars["dateOfBirth"], "1995-08-13"); + assert_eq!(lars["nationality"], "Norway"); + assert_eq!(lars["attributes"]["Aerial Reach"], 15); + assert_eq!(lars["attributes"]["Communication"], 12); + assert!(lars["attributes"].get("Consistency").is_none()); + assert!(lars["attributes"].get("Injury Proneness").is_none()); } #[cfg(target_os = "windows")] @@ -1669,10 +2026,7 @@ mod tests { ); assert_eq!(snapshot.status.database_index_status, "ready"); assert_eq!(snapshot.status.database_scope, "full-save-index"); - assert!( - snapshot.status.database_players_indexed - >= snapshot.status.managed_squad_players - ); + assert!(snapshot.status.database_players_indexed >= snapshot.status.managed_squad_players); assert!(snapshot.status.background_players_indexed > 0); assert_eq!( snapshot.status.visible_players_loaded, @@ -1683,4 +2037,37 @@ mod tests { .iter() .all(|player| player["visibility"] == "club-visible")); } + + #[test] + fn fm_dates_and_age_match_the_current_save_calendar() { + let birth = FmDate { + year: 1995, + day_of_year: 225, + }; + let current = FmDate { + year: 2026, + day_of_year: 150, + }; + assert_eq!(format_fm_date(birth).as_deref(), Some("1995-08-13")); + assert_eq!(format_fm_date(current).as_deref(), Some("2026-05-30")); + assert_eq!(calculate_age(birth, current), Some(30)); + } + + #[test] + fn hidden_and_foot_storage_values_never_enter_visible_attributes() { + let raw = vec![50_u8; PLAYER_ATTRIBUTE_NAMES.len()]; + let attributes = visible_attribute_map(&raw); + assert_eq!(attributes.get("Crossing"), Some(&10)); + for hidden in [ + "Dirtiness", + "Consistency", + "Important Matches", + "Injury Proneness", + "Versatility", + "Left Foot", + "Right Foot", + ] { + assert!(!attributes.contains_key(hidden)); + } + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2fcb1d2..8fd1177 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ +#![recursion_limit = "256"] + mod connector; +mod player_face; mod tactic_file; mod visibility; @@ -25,6 +28,7 @@ pub fn run() { connector::connector_snapshot, connector::load_active_save, connector::search_indexed_players, + player_face::player_face_data, tactic_file::import_tactic_file, tactic_file::tactic_file_status, visibility::filter_observations diff --git a/src-tauri/src/player_face.rs b/src-tauri/src/player_face.rs new file mode 100644 index 0000000..8cbbb40 --- /dev/null +++ b/src-tauri/src/player_face.rs @@ -0,0 +1,159 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Serialize; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +const MAX_FACE_BYTES: u64 = 8 * 1024 * 1024; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerFaceResult { + found: bool, + player_id: String, + data_url: Option, + source: &'static str, +} + +#[tauri::command] +pub fn player_face_data(player_id: String, icon: bool) -> PlayerFaceResult { + let player_id = player_id.trim().to_string(); + if player_id.is_empty() || !player_id.bytes().all(|byte| byte.is_ascii_digit()) { + return missing(player_id); + } + + let Some(path) = resolve_face_path(&player_id, icon) else { + return missing(player_id); + }; + let Ok(metadata) = path.metadata() else { + return missing(player_id); + }; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_FACE_BYTES { + return missing(player_id); + } + let Some(mime) = image_mime(&path) else { + return missing(player_id); + }; + let Ok(bytes) = fs::read(path) else { + return missing(player_id); + }; + + PlayerFaceResult { + found: true, + player_id, + data_url: Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes))), + source: "fm-unique-id", + } +} + +fn missing(player_id: String) -> PlayerFaceResult { + PlayerFaceResult { + found: false, + player_id, + data_url: None, + source: "fallback", + } +} + +fn image_mime(path: &Path) -> Option<&'static str> { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "webp" => Some("image/webp"), + _ => None, + } +} + +fn resolve_face_path(player_id: &str, icon: bool) -> Option { + let folder = if icon { "iconfaces" } else { "faces" }; + for root in graphics_roots() { + let directory = root.join(folder); + for stem in [ + format!("face_{player_id}"), + player_id.to_string(), + format!("iconface_{player_id}"), + ] { + for extension in ["png", "jpg", "jpeg", "webp"] { + let path = directory.join(format!("{stem}.{extension}")); + if path.is_file() { + return Some(path); + } + } + } + + let config = directory.join("config.xml"); + if let Some(path) = resolve_from_config(&config, &directory, player_id) { + return Some(path); + } + } + None +} + +fn graphics_roots() -> Vec { + let mut roots = Vec::new(); + if let Some(home) = env::var_os("USERPROFILE").map(PathBuf::from) { + roots.push( + home.join("Documents") + .join("Sports Interactive") + .join("Football Manager 26") + .join("graphics"), + ); + roots.push( + home.join("OneDrive") + .join("Documents") + .join("Sports Interactive") + .join("Football Manager 26") + .join("graphics"), + ); + } + roots +} + +fn resolve_from_config(config: &Path, directory: &Path, player_id: &str) -> Option { + let metadata = config.metadata().ok()?; + if metadata.len() > 32 * 1024 * 1024 { + return None; + } + let content = fs::read_to_string(config).ok()?; + let marker = format!("person/{player_id}/"); + for line in content.lines().filter(|line| line.contains(&marker)) { + let from = attribute_value(line, "from")?; + let relative = Path::new(from); + if relative.components().count() != 1 { + continue; + } + for extension in ["png", "jpg", "jpeg", "webp"] { + let path = directory.join(format!("{from}.{extension}")); + if path.is_file() { + return Some(path); + } + } + } + None +} + +fn attribute_value<'a>(line: &'a str, name: &str) -> Option<&'a str> { + let start_marker = format!("{name}=\""); + let start = line.find(&start_marker)? + start_marker.len(); + let end = line[start..].find('"')? + start; + line.get(start..end) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_attribute_is_read_without_accepting_paths() { + let line = + r#""#; + assert_eq!(attribute_value(line, "from"), Some("face_2000370823")); + } + + #[test] + fn only_supported_image_types_receive_a_mime() { + assert_eq!(image_mime(Path::new("face_1.png")), Some("image/png")); + assert_eq!(image_mime(Path::new("face_1.svg")), None); + } +} diff --git a/src-tauri/src/tactic_file.rs b/src-tauri/src/tactic_file.rs index d060f4e..0ff34d2 100644 --- a/src-tauri/src/tactic_file.rs +++ b/src-tauri/src/tactic_file.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ fs::{self, File}, - io::Read, + io::{Cursor, Read}, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -10,7 +10,9 @@ use tauri::{AppHandle, Manager}; const MAX_FMF_FILE_BYTES: u64 = 128 * 1024 * 1024; const MAX_INSPECTION_BYTES: usize = 2 * 1024 * 1024; +const MAX_DECOMPRESSED_INDEX_BYTES: u64 = 2 * 1024 * 1024; const FMF_CONTAINER_MAGIC: [u8; 6] = [0x02, 0x01, b'a', b'f', b'e', b'.']; +const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd]; const STATUS_FILE_NAME: &str = "current-tactic.json"; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -36,6 +38,7 @@ pub struct TacticFileResult { pub parsed_roles: Vec, pub parsed_duties: Vec, pub detected_format: Option, + pub archive_entries: Vec, pub compressed: Option, pub encoded: Option, pub warnings: Vec, @@ -54,6 +57,7 @@ impl TacticFileResult { parsed_roles: Vec::new(), parsed_duties: Vec::new(), detected_format: None, + archive_entries: Vec::new(), compressed: None, encoded: None, warnings: Vec::new(), @@ -235,21 +239,42 @@ fn inspect_fmf(bytes: &[u8], file_size: u64) -> TacticFileResult { } if bytes.starts_with(&FMF_CONTAINER_MAGIC) { + let archive_entries = inspect_archive_index(bytes).unwrap_or_default(); + let index_decoded = !archive_entries.is_empty(); return TacticFileResult { success: true, file_name: None, file_size: Some(file_size), imported_at: None, - parser_status: TacticParserStatus::ImportedUnparsed, + parser_status: if index_decoded { + TacticParserStatus::PartiallyParsed + } else { + TacticParserStatus::ImportedUnparsed + }, parsed_formation: None, parsed_roles: Vec::new(), parsed_duties: Vec::new(), - detected_format: Some("sports-interactive-fmf-container".to_string()), + detected_format: Some( + if index_decoded { + "fmf-v2-afe-zstd-index" + } else { + "sports-interactive-fmf-container" + } + .to_string(), + ), + archive_entries, compressed: Some(true), encoded: Some(true), warnings: vec![ "A Sports Interactive FMF container was detected.".to_string(), - "Tactic file imported, but this FMF format is not fully decoded yet.".to_string(), + if index_decoded { + "The FMF archive index was decoded safely, but the tactic payload remains encrypted by the current FM26 container format.".to_string() + } else { + "Tactic file imported, but this FMF format is not fully decoded yet." + .to_string() + }, + "No formation, role or duty is shown until the tactic payload itself is decoded." + .to_string(), ], errors: Vec::new(), }; @@ -275,6 +300,7 @@ fn inspect_fmf(bytes: &[u8], file_size: u64) -> TacticFileResult { parsed_roles: Vec::new(), parsed_duties: Vec::new(), detected_format: Some(format.to_string()), + archive_entries: Vec::new(), compressed: Some(compressed), encoded: Some(!compressed), warnings: vec![ @@ -285,6 +311,59 @@ fn inspect_fmf(bytes: &[u8], file_size: u64) -> TacticFileResult { } } +fn inspect_archive_index(bytes: &[u8]) -> Option> { + let offset = bytes + .windows(ZSTD_MAGIC.len()) + .position(|window| window == ZSTD_MAGIC)?; + let cursor = Cursor::new(bytes.get(offset..)?); + let decoder = zstd::stream::read::Decoder::new(cursor).ok()?; + let mut unpacked = Vec::new(); + decoder + .take(MAX_DECOMPRESSED_INDEX_BYTES + 1) + .read_to_end(&mut unpacked) + .ok()?; + if unpacked.len() as u64 > MAX_DECOMPRESSED_INDEX_BYTES { + return None; + } + let strings = length_prefixed_strings(&unpacked); + let mut entries = Vec::new(); + for pair in strings.windows(2) { + if pair[1].starts_with('.') && pair[1].len() <= 8 { + let entry = format!("{}{}", pair[0], pair[1]); + if !entries.contains(&entry) { + entries.push(entry); + } + } + } + (!entries.is_empty()).then_some(entries) +} + +fn length_prefixed_strings(bytes: &[u8]) -> Vec { + let mut strings = Vec::new(); + let mut offset = 0_usize; + while offset + 4 <= bytes.len() { + let length = + u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("four bytes")) as usize; + let Some(end) = offset + .checked_add(4) + .and_then(|start| start.checked_add(length)) + else { + break; + }; + if (1..=64).contains(&length) && end <= bytes.len() { + if let Ok(value) = std::str::from_utf8(&bytes[offset + 4..end]) { + if value.chars().all(|character| !character.is_control()) { + strings.push(value.to_string()); + offset = end; + continue; + } + } + } + offset += 1; + } + strings +} + fn sha256_file(path: &Path) -> Option { let mut file = File::open(path).ok()?; let mut hasher = Sha256::new(); @@ -318,7 +397,7 @@ mod tests { assert!(result.success); assert!(matches!( result.parser_status, - TacticParserStatus::ImportedUnparsed + TacticParserStatus::ImportedUnparsed | TacticParserStatus::PartiallyParsed )); assert!(result.parsed_formation.is_none()); assert!(result.parsed_roles.is_empty()); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8dc3f11..6a4cfd4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GlassScout FM26", - "version": "0.1.4", + "version": "0.1.5", "identifier": "com.glassscout.fm26", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/app/globals.css b/src/app/globals.css index b33d94a..a577c36 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -773,3 +773,72 @@ body{background:#f8fbfc;color:var(--navy);font-size:14px} .phase-toggle{min-width:320px}.tactical-board-compact .pitch-canvas{height:380px} .recruitment-pulse{min-height:468px}.dossier-layout{grid-template-columns:minmax(620px,2fr) 290px}.player-facts{grid-template-columns:repeat(5,1fr)} } + +/* 0.1.5 — flat controls and dossier-consistent live-data views */ +button, +[data-slot="button"]{ + box-shadow:none!important; + background-image:none!important; + transform:none; +} +button:active, +[data-slot="button"]:active{transform:none!important} +.nav-item-active{background:#e8f6f3!important} +.phase-toggle button.active{background:#078c7c!important;box-shadow:none!important} +.setup-download{box-shadow:none!important} + +.player-face{ + display:grid; + place-items:center; + flex:0 0 auto; + overflow:hidden; + border:1px solid #c9dde3; + border-radius:50%; + background:#f2f8f7; + color:#4c6680; +} +.player-face img{width:100%;height:100%;object-fit:cover} +.player-face svg{width:45%;height:45%;stroke-width:1.5} +.player-face-sm{width:40px;height:40px} +.player-face-md{width:58px;height:58px} +.player-face-lg{width:68px;height:68px} +.dossier-heading .player-face-lg{margin-left:2px} + +.squad-player-identity{display:flex!important;flex-direction:row!important;align-items:center;gap:10px} +.squad-player-identity>span{display:flex;min-width:0;flex-direction:column;gap:3px} +.squad-player-identity strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.player-name-link{padding:0;border:0;background:transparent;color:var(--navy);font-size:8px;font-weight:700;text-align:left} +.player-name-link:hover{color:var(--primary);text-decoration:underline;text-underline-offset:2px} +.squad-fit-cell{align-items:center!important;text-align:center} +.fit-score-ring{ + --fit-score:0deg; + --fit-color:#aebbc6; + position:relative; + display:grid; + place-items:center; + width:40px; + height:40px; + border-radius:50%; + background:conic-gradient(var(--fit-color) var(--fit-score),#e4ebef 0); +} +.fit-score-ring:after{content:"";position:absolute;inset:4px;border-radius:50%;background:#fff} +.fit-score-ring strong{position:relative;z-index:1;font-size:10px} +.fit-score-strong{--fit-color:#19a98f} +.fit-score-medium{--fit-color:#e3a12a} +.fit-score-poor{--fit-color:#cf625c} +.fit-score-unknown{--fit-color:#b9c4cc} + +.scout-command-bar{ + grid-template-columns:minmax(300px,2fr) repeat(4,minmax(115px,1fr)); + align-items:stretch; +} +.scout-filter{padding-right:10px!important} +.scout-room-board>header, +.scout-room-board>article{ + grid-template-columns:minmax(235px,1.45fr) minmax(175px,1fr) 105px 105px 110px 100px 130px 68px; + min-width:1160px; +} +.scout-fit-cell{display:flex!important;flex-direction:row!important;align-items:center;gap:9px} +.scout-fit-cell>span:last-child{display:flex;min-width:0;flex-direction:column;gap:3px} +.knowledge-gate-note{margin:12px 2px 0;color:var(--slate);font-size:10px} +.target-identity>.player-face{width:42px;height:42px} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 4510007..21c9256 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -4,14 +4,10 @@ import { Binoculars, CalendarDays, ChevronLeft, - ClipboardList, LayoutDashboard, PanelsTopLeft, - SearchCheck, Settings, - ShieldCheck, Star, - UserRoundSearch, UsersRound, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -19,25 +15,19 @@ import { cn } from "@/lib/utils"; export type Screen = | "Dashboard" - | "Squad Planner" + | "Squad" | "Tactical Board" | "Scout Room" - | "Recruitment" - | "Players" | "Shortlist" - | "Reports" | "Player Profile" | "Settings"; const navigation = [ { label: "Dashboard", icon: LayoutDashboard }, - { label: "Squad Planner", icon: UsersRound }, + { label: "Squad", icon: UsersRound }, { label: "Tactical Board", icon: PanelsTopLeft }, { label: "Scout Room", icon: Binoculars }, - { label: "Recruitment", icon: SearchCheck }, - { label: "Players", icon: UserRoundSearch }, { label: "Shortlist", icon: Star }, - { label: "Reports", icon: ClipboardList }, ] satisfies { label: Screen; icon: typeof LayoutDashboard }[]; export function AppSidebar({ @@ -69,13 +59,6 @@ export function AppSidebar({
-
- -
- Revealed data only - Hidden values blocked before scoring. -
-
diff --git a/src/components/dashboard-screen.tsx b/src/components/dashboard-screen.tsx index d43e41e..9d58533 100644 --- a/src/components/dashboard-screen.tsx +++ b/src/components/dashboard-screen.tsx @@ -98,9 +98,9 @@ export function DashboardScreen({ { icon: ShieldCheck, title: `${squad.length} managed-club players visibility-safe`, - detail: "Names and positions are live; unsupported fields remain Unknown.", + detail: "IDs, ages, nations, positions, feet and visible attributes are live; unsupported fields remain Unknown.", action: "Open squad", - screen: "Squad Planner" as Screen, + screen: "Squad" as Screen, }, ]; @@ -110,7 +110,7 @@ export function DashboardScreen({
-

Recruitment pulse

{externalVisible.length} live targets
+

Scout Room pulse

{externalVisible.length} live targets
@@ -122,7 +122,7 @@ export function DashboardScreen({

-
diff --git a/src/components/favorited-players-screen.tsx b/src/components/favorited-players-screen.tsx index 5921cfb..93811da 100644 --- a/src/components/favorited-players-screen.tsx +++ b/src/components/favorited-players-screen.tsx @@ -75,7 +75,7 @@ export function FavoritedPlayersScreen({ ))} ) : ( -

No favorited players

Add players from Recruitment. Favorites never create or preserve stale player records; they resolve against the latest live FM26 snapshot.

+

No favorited players

Add players from Scout Room. Favorites never create or preserve stale player records; they resolve against the latest live FM26 snapshot.

)} {compareIds.length === 2 ? ( diff --git a/src/components/glass-scout-app.tsx b/src/components/glass-scout-app.tsx index 2fe5665..b3725b2 100644 --- a/src/components/glass-scout-app.tsx +++ b/src/components/glass-scout-app.tsx @@ -7,8 +7,7 @@ import { Topbar } from "@/components/topbar"; import { DashboardScreen } from "@/components/dashboard-screen"; import { MyTeamScreen } from "@/components/my-team-screen"; import { TacticsScreen } from "@/components/tactics-screen"; -import { RoleDnaScreen } from "@/components/role-dna-screen"; -import { RecruitmentScreen } from "@/components/recruitment-screen"; +import { ScoutRoomScreen } from "@/components/recruitment-screen"; import { FavoritedPlayersScreen } from "@/components/favorited-players-screen"; import { PlayerProfileScreen } from "@/components/player-profile-screen"; import { StartupScreen } from "@/components/startup-screen"; @@ -150,10 +149,9 @@ export function GlassScoutApp() { const content = screen === "Dashboard" ? : - screen === "Squad Planner" ? : + screen === "Squad" ? : screen === "Tactical Board" ? : - screen === "Reports" ? : - screen === "Recruitment" || screen === "Scout Room" || screen === "Players" ? : + screen === "Scout Room" ? : screen === "Shortlist" ? : screen === "Player Profile" ? ( record.playerId === selectedPlayerId) : false} onToggleFavorite={() => selectedPlayerId && togglePlayerFavorite(selectedPlayerId)} - onBack={() => setScreen("Players")} + onBack={() => setScreen("Scout Room")} /> ) : ; diff --git a/src/components/my-team-screen.tsx b/src/components/my-team-screen.tsx index f7ad06a..80dba5b 100644 --- a/src/components/my-team-screen.tsx +++ b/src/components/my-team-screen.tsx @@ -5,15 +5,48 @@ import { ExternalLink, ShieldCheck, Target, UsersRound } from "lucide-react"; import type { LiveFootballSnapshot, LivePlayer } from "@/domain/adapters"; import { groupSquad, positionGroups } from "@/domain/live-data"; import { LiveDataState } from "@/components/live-data-state"; +import { PlayerFace } from "@/components/player-face"; import { Button } from "@/components/ui/button"; +function scoreTone(value: number | null | undefined) { + if (value == null) return "unknown"; + if (value >= 70) return "strong"; + if (value >= 50) return "medium"; + return "poor"; +} + +function FitRing({ value }: { value: number | null | undefined }) { + const safeValue = value == null ? 0 : Math.max(0, Math.min(100, value)); + return ( + + {value ?? "—"} + + ); +} + +function valueOrUnknown(value: string | number | null | undefined) { + return value == null || value === "" ? "Unknown" : value; +} + function PlayerRow({ player, onOpenPlayer }: { player: LivePlayer; onOpenPlayer: (playerId: string) => void }) { return ( -
-
{player.name}FM26 ID {player.id}
- {player.positions.join(" / ") || "Unknown"}Live positional familiarity - {player.bestCalculatedPosition ?? "Unavailable"}Strongest readable position - {(player.scoutKnowledge ?? "unknown").replaceAll("_", " ")}Managed squad +
+
+ + {valueOrUnknown(player.nationality)} · {valueOrUnknown(player.age)} yrs +
+ {player.positions.join(" / ") || "Unknown"}{player.preferredFoot ? `${player.preferredFoot} foot` : "Foot unknown"} + {valueOrUnknown(player.bestRole)}Visible-attribute role + Role evidence + {valueOrUnknown(player.averageRating?.toFixed(2))}Current form + {valueOrUnknown(player.contractStatus)}{valueOrUnknown(player.wage)} wage + {valueOrUnknown(player.value)}Valuation + UnknownFitness / injury + {valueOrUnknown(player.squadImportance)}Squad status
); @@ -36,26 +69,27 @@ export function MyTeamScreen({ ); const groups = useMemo(() => groupSquad(squad), [squad]); const managedClub = snapshot.clubs.find((club) => club.id === snapshot.managedClubId); + const mappedAttributes = squad.reduce((sum, player) => sum + Object.keys(player.attributes ?? {}).length, 0); if (snapshot.status.state !== "connected" || !snapshot.managedClubId || squad.length === 0) { - return
; + return
; } return (
-

Squad Planner

{managedClub?.name} · {snapshot.season ?? "Active FM26 save"} · {squad.length} current players

+

Squad

{managedClub?.name} · {snapshot.season ?? "Active FM26 save"} · {squad.length} current players

Live FM26
-
Live squad players{squad.length}
-
Names validated{squad.filter((player) => player.name.length > 0).length}
-
Positions validated{squad.filter((player) => player.positions.length > 0).length}
+
Current squad{squad.length}
+
Fully known{squad.filter((player) => player.scoutKnowledge === "fully_known").length}
+
Visible attributes read{mappedAttributes}
-
-
PlayerPositionsPrimaryKnowledgeDetails
+
+
PlayerPositionBest roleRatingFormContract / wageValueConditionStatusDetails
{positionGroups.map((group) => { const players = groups.get(group) ?? []; if (players.length === 0) return null; @@ -69,12 +103,12 @@ export function MyTeamScreen({
-

Live data coverage

+

Data coverage

-
Managed club

{managedClub?.name} and {squad.length} current squad records are validated directly from the active save.

-
Current tactic

{snapshot.tactic?.name ?? "The current tactic is unavailable."}

-
Position depth

{positionGroups.map((group) => `${group}: ${groups.get(group)?.length ?? 0}`).join(" · ")}

-
Unavailable fields

Age, attributes, roles, form, contracts, wages and valuations are left blank until their live records are mapped safely.

+
Verified now

FM unique ID, age, date of birth, nationality, positions, preferred foot and visible 1–20 attributes.

+
Rating method

The circular score uses position-relevant visible attributes only. It is not hidden current ability and is not tactic fit without a decoded tactic.

+
Current tactic

{snapshot.tactic?.formation ?? "Import an FMF tactic to add formation-specific fit."}

+
Still unknown

Form, contracts, wages, valuation, fitness and squad status remain blank until their current-build relationships are validated.

diff --git a/src/components/player-face.tsx b/src/components/player-face.tsx new file mode 100644 index 0000000..ad2b7cc --- /dev/null +++ b/src/components/player-face.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { UserRound } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { cn } from "@/lib/utils"; + +type PlayerFaceResult = { + found: boolean; + playerId: string; + dataUrl: string | null; + source: "fm-unique-id" | "fallback"; +}; + +const faceCache = new Map(); + +export function PlayerFace({ + playerId, + name, + size = "md", +}: { + playerId: string; + name: string; + size?: "sm" | "md" | "lg"; +}) { + const cacheKey = `${playerId}:${size === "sm" ? "icon" : "portrait"}`; + const [source, setSource] = useState(() => faceCache.get(cacheKey)); + + useEffect(() => { + if (faceCache.has(cacheKey) || !("__TAURI_INTERNALS__" in window)) return; + let active = true; + invoke("player_face_data", { playerId, icon: size === "sm" }) + .then((result) => { + const next = result.found ? result.dataUrl : null; + faceCache.set(cacheKey, next); + if (active) setSource(next); + }) + .catch(() => { + faceCache.set(cacheKey, null); + if (active) setSource(null); + }); + return () => { + active = false; + }; + }, [cacheKey, playerId, size]); + + return ( + + {source ? {`${name} : } + + ); +} diff --git a/src/components/player-profile-screen.tsx b/src/components/player-profile-screen.tsx index 53140bb..c94d05e 100644 --- a/src/components/player-profile-screen.tsx +++ b/src/components/player-profile-screen.tsx @@ -17,11 +17,13 @@ import { } from "lucide-react"; import type { LiveFootballSnapshot, LivePlayer } from "@/domain/adapters"; import { Button } from "@/components/ui/button"; +import { ConfidenceRing } from "@/components/confidence-ring"; +import { PlayerFace } from "@/components/player-face"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const technicalAttributes = ["Technique", "Dribbling", "Passing", "First Touch", "Finishing", "Crossing", "Long Shots"]; -const mentalAttributes = ["Vision", "Decisions", "Composure", "Anticipation", "Work Rate", "Flair", "Consistency"]; -const physicalAttributes = ["Acceleration", "Pace", "Agility", "Balance", "Strength", "Stamina", "Injury Proneness"]; +const mentalAttributes = ["Vision", "Decisions", "Composure", "Anticipation", "Work Rate", "Flair", "Teamwork"]; +const physicalAttributes = ["Acceleration", "Pace", "Agility", "Balance", "Strength", "Stamina", "Natural Fitness"]; function evidenceValue(player: LivePlayer, attribute: string) { const value = player.attributes?.[attribute]; @@ -39,10 +41,6 @@ function AttributeGroup({ title, names, player }: { title: string; names: string ); } -function UnknownRing() { - return Unknown; -} - export function PlayerProfileScreen({ player, snapshot, @@ -67,7 +65,7 @@ export function PlayerProfileScreen({ const club = player.clubId ? snapshot.clubs.find((item) => item.id === player.clubId) : null; const mappedAttributeCount = Object.values(player.attributes ?? {}).filter((value) => typeof value === "number").length; - const knownEvidence = Math.min(100, Math.round(((2 + mappedAttributeCount) / 23) * 100)); + const knownEvidence = player.scoutConfidence ?? Math.min(100, Math.round((mappedAttributeCount / 47) * 100)); const unknownEvidence = 100 - knownEvidence; return ( @@ -75,6 +73,7 @@ export function PlayerProfileScreen({
+

{player.name}

Visibility-safe live FM26 dossier

@@ -87,18 +86,18 @@ export function PlayerProfileScreen({
Nationality{player.nationality ?? "Unknown"} Club{club?.name ?? "Unknown"} - Age{player.age ?? "Unknown"} + Age / DOB{player.age ?? "Unknown"}{player.dateOfBirth ? ` · ${player.dateOfBirth}` : ""} Position{player.positions.join(" / ") || "Unknown"} - Preferred footUnknown + Preferred foot{player.preferredFoot ?? "Unknown"} Value{player.value ?? "Unknown"} Wage{player.wage ?? "Unknown"} Contract{player.contractStatus ?? "Unknown"} - Scout confidence + Knowledge{player.scoutConfidence == null ? Unknown : }
- {["overview", "tactical", "attributes", "performance", "career", "reports", "notes"].map((value) => ( + {["overview", "tactical", "attributes", "performance", "career", "notes"].map((value) => ( {value === "tactical" ? "Tactical fit" : value[0].toUpperCase() + value.slice(1)} @@ -111,23 +110,23 @@ export function PlayerProfileScreen({

Scout summary

- Live memory confirms {player.name} belongs to {club?.name ?? "the current club"} and is familiar with {player.positions.join(", ") || "an unknown position"}. - Age, nationality, attributes, form, contract, wage and valuation are not mapped for this FM26 build, so GlassScout does not produce an ability or recruitment recommendation. + Live memory confirms {player.name} ({player.nationality ?? "nationality unknown"}, age {player.age ?? "unknown"}) belongs to {club?.name ?? "the current club"}. + The role evidence score uses only visible FM26 attributes for {player.bestRole ?? player.positions[0] ?? "the player’s position"}; hidden current and potential ability are never read.

-

Strengths

Unknown — no visible attribute evidence
-

Weaknesses

Unknown — no visible attribute evidence
-

Recommended next action

Collect a visibility-safe scout report before making a decision.
+

Strengths

{player.strengths.length ? player.strengths.join(" · ") : "No visible evidence"}
+

Weaknesses

{player.weaknesses.length ? player.weaknesses.join(" · ") : "No visible evidence"}
+

Recommended next action

{snapshot.tactic ? "Review formation-specific fit." : "Import an FMF tactic before judging tactical fit."}
-

Role & tactical fit

+

{snapshot.tactic ? "Role & tactical fit" : "Role evidence"}

Best role{player.bestRole ?? "Unknown"} - Fit range{player.roleFit == null ? "Unknown" : `${player.roleFit}%`} - Scout confidenceUnknown + {snapshot.tactic ? "Tactical fit" : "Role evidence"}{player.roleFit == null ? "Unknown" : `${player.roleFit}%`} + Knowledge{player.scoutConfidence == null ? "Unknown" : `${player.scoutConfidence}%`}
@@ -164,9 +163,9 @@ export function PlayerProfileScreen({
-
Last scouted
Unknown
+
Last scouted
{player.lastScoutedDate ?? "Unknown"}
Observations
Unknown
-
Report reliability
Unknown
+
Report reliability
{player.reportReliability ?? "Unknown"}
@@ -197,7 +196,7 @@ export function PlayerProfileScreen({
- {["tactical", "attributes", "performance", "career", "reports", "notes"].map((value) => ( + {["tactical", "attributes", "performance", "career", "notes"].map((value) => (
diff --git a/src/components/recruitment-screen.tsx b/src/components/recruitment-screen.tsx index ae8b43b..f21d014 100644 --- a/src/components/recruitment-screen.tsx +++ b/src/components/recruitment-screen.tsx @@ -1,34 +1,29 @@ "use client"; import { useMemo, useState } from "react"; -import { - ChevronDown, - Database, - ExternalLink, - Search, - ShieldCheck, - Star, -} from "lucide-react"; +import { Database, ExternalLink, Search, ShieldCheck, Star } from "lucide-react"; import type { LiveFootballSnapshot, LivePlayer } from "@/domain/adapters"; import type { FavoriteRecord } from "@/domain/live-data"; import { LiveDataState } from "@/components/live-data-state"; +import { PlayerFace } from "@/components/player-face"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -const knownInterest = new Set([ - "Very interested", - "Interested", - "Slightly interested", - "Only loan possible", - "Only if promoted", - "Only if wages are improved", - "Only if guaranteed playing time", -]); - function interestLabel(player: LivePlayer) { return player.transferInterest ?? player.loanInterest ?? "Unknown"; } +function fitLabel(player: LivePlayer) { + if (player.tacticalFit == null) return "Tactic unavailable"; + if (player.tacticalFit >= 70) return "Strong"; + if (player.tacticalFit >= 50) return "Medium"; + return "Low"; +} + +function knowledgeLabel(player: LivePlayer) { + return (player.scoutKnowledge ?? "unknown").replaceAll("_", " "); +} + function availabilityLabel(player: LivePlayer) { if (player.transferAvailable === true) return "Transfer"; if (player.loanAvailable === true) return "Loan"; @@ -36,16 +31,41 @@ function availabilityLabel(player: LivePlayer) { return "Unknown"; } -function realismLabel(player: LivePlayer) { - const interest = interestLabel(player); - if (interest === "Not interested") return "Not interested"; - if (interest === "Only loan possible") return "Loan only"; - if (!knownInterest.has(interest)) return "Unknown"; - if (!player.value || !player.wage) return "Needs financial evidence"; - return "Interest recorded"; +function Filter({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; +}) { + return ( + + ); } -export function RecruitmentScreen({ +function RatingRing({ value }: { value: number | null }) { + const safe = value == null ? 0 : Math.max(0, Math.min(100, value)); + const tone = value == null ? "unknown" : value >= 70 ? "strong" : value >= 50 ? "medium" : "poor"; + return ( + + {value ?? "—"} + + ); +} + +export function ScoutRoomScreen({ snapshot, favorites, checking, @@ -61,88 +81,96 @@ export function RecruitmentScreen({ onOpenPlayer: (playerId: string) => void; }) { const [query, setQuery] = useState(""); - const [position, setPosition] = useState("All positions"); - const [interest, setInterest] = useState("All interest"); - const [realism, setRealism] = useState("All realism"); - const [transferType, setTransferType] = useState("All types"); + const [position, setPosition] = useState("All"); + const [nation, setNation] = useState("All"); + const [role, setRole] = useState("All"); + const [age, setAge] = useState("All"); + const [fit, setFit] = useState("All"); + const [interest, setInterest] = useState("All"); + const [knowledge, setKnowledge] = useState("All"); + const [contract, setContract] = useState("All"); + const [wage, setWage] = useState("All"); + const [value, setValue] = useState("All"); + const [transferType, setTransferType] = useState("All"); + const [recommendation, setRecommendation] = useState("All"); const clubById = useMemo(() => new Map(snapshot.clubs.map((club) => [club.id, club])), [snapshot.clubs]); const favoriteIds = useMemo(() => new Set(favorites.map((record) => record.playerId)), [favorites]); - const visibleTargets = useMemo( - () => snapshot.players.filter((player) => player.clubId !== snapshot.managedClubId), - [snapshot.managedClubId, snapshot.players], - ); - const positions = useMemo( - () => ["All positions", ...new Set(visibleTargets.flatMap((player) => player.positions))], - [visibleTargets], - ); + const positions = useMemo(() => ["All", ...new Set(snapshot.players.flatMap((player) => player.positions))], [snapshot.players]); + const nations = useMemo(() => ["All", ...new Set(snapshot.players.flatMap((player) => player.nationality ? [player.nationality] : []))], [snapshot.players]); + const roles = useMemo(() => ["All", ...new Set(snapshot.players.flatMap((player) => player.bestRole ? [player.bestRole] : []))], [snapshot.players]); + const players = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - return visibleTargets + const normalized = query.trim().toLowerCase(); + return snapshot.players .filter((player) => { const club = player.clubId ? clubById.get(player.clubId) : null; - const matchesQuery = !normalizedQuery || [player.name, club?.name, player.nationality, ...player.positions] + const matchesQuery = !normalized || [player.name, club?.name, player.nationality, ...player.positions] .filter(Boolean) - .some((value) => value?.toLowerCase().includes(normalizedQuery)); - const matchesPosition = position === "All positions" || player.positions.includes(position); - const playerInterest = interestLabel(player); - const playerRealism = realismLabel(player); - const playerType = availabilityLabel(player); + .some((item) => item?.toLowerCase().includes(normalized)); + const ageBand = player.age == null ? "Unknown" : player.age <= 21 ? "U21" : player.age <= 27 ? "22–27" : "28+"; + const playerRecommendation = player.clubId === snapshot.managedClubId ? "Current squad" : "Unknown"; return matchesQuery - && matchesPosition - && (interest === "All interest" || playerInterest === interest) - && (realism === "All realism" || playerRealism === realism) - && (transferType === "All types" || playerType === transferType); + && (position === "All" || player.positions.includes(position)) + && (nation === "All" || player.nationality === nation) + && (role === "All" || player.bestRole === role) + && (age === "All" || ageBand === age) + && (fit === "All" || fitLabel(player) === fit) + && (interest === "All" || interestLabel(player) === interest) + && (knowledge === "All" || knowledgeLabel(player) === knowledge) + && (contract === "All" || (contract === "Known") === Boolean(player.contractStatus)) + && (wage === "All" || (wage === "Known") === Boolean(player.wage)) + && (value === "All" || (value === "Known") === Boolean(player.value)) + && (transferType === "All" || availabilityLabel(player) === transferType) + && (recommendation === "All" || playerRecommendation === recommendation); }) - .toSorted((a, b) => { - const aKnown = knownInterest.has(interestLabel(a)) ? 1 : 0; - const bKnown = knownInterest.has(interestLabel(b)) ? 1 : 0; - if (aKnown !== bKnown) return bKnown - aKnown; - return (b.tacticalFit ?? -1) - (a.tacticalFit ?? -1); - }); - }, [clubById, interest, position, query, realism, transferType, visibleTargets]); + .toSorted((left, right) => (right.roleFit ?? -1) - (left.roleFit ?? -1) || left.name.localeCompare(right.name)); + }, [age, clubById, contract, fit, interest, knowledge, nation, position, query, recommendation, role, snapshot.managedClubId, snapshot.players, transferType, value, wage]); if (snapshot.status.state !== "connected") { - return
; + return
; } return ( -
+
-
-

Recruitment Hub

-

Only club-visible players can enter this decision workflow.

-
+

Scout Room

Player database, squad evidence, shortlist and recruitment decisions in one workspace.

{snapshot.status.databasePlayersIndexed} indexed · {snapshot.status.visiblePlayersLoaded} visible
-
-
setQuery(event.target.value)} />
- - - - +
+
setQuery(event.target.value)} />
+ + + + + + + + + + + +
-
-
- PlayerRole & tactical fitKnowledgeInterest & realismFinancialsAvailabilityProjection / riskNext action -
+
+
PlayerRole evidenceKnowledgeInterestFinancialsAvailabilityRecommendationActions
{players.map((player) => { const club = player.clubId ? clubById.get(player.clubId) : null; const favorite = favoriteIds.has(player.id); return (
- {player.name.split(" ").map((part) => part[0]).join("").slice(0, 2)} +
{player.name}{player.age ?? "Age unknown"} · {player.nationality ?? "Nation unknown"} · {player.positions.join(" / ") || "Position unknown"}{club?.name ?? "Club unknown"}
-
{player.bestRole ?? "Unknown"}Tactical fit {player.tacticalFit == null ? "Unknown" : `${player.tacticalFit}%`}
-
{player.scoutKnowledge?.replaceAll("_", " ") ?? "Unknown"}Confidence Unknown
-
{interestLabel(player)}{realismLabel(player)}
+
{player.bestRole ?? "Unknown"}{player.tacticalFit == null ? "Tactic fit unavailable" : `${player.tacticalFit}% tactic fit`}
+
{knowledgeLabel(player)}{player.scoutConfidence == null ? "Confidence unknown" : `${player.scoutConfidence}% confidence`}
+
{interestLabel(player)}Realism unknown
{player.value ?? "Value unknown"}{player.wage ?? "Wage unknown"}
{availabilityLabel(player)}{player.contractStatus ?? "Contract unknown"}
-
Projection unknown{player.riskLevel === "unknown" || !player.riskLevel ? "Risk unknown" : `${player.riskLevel} risk`}
+
{player.clubId === snapshot.managedClubId ? "Current squad" : "No recommendation"}Visible evidence only
@@ -150,16 +178,12 @@ export function RecruitmentScreen({
); })} - {!players.length ? ( -
- -

No visibility-safe recruitment targets

-

- GlassScout indexed {snapshot.status.backgroundPlayersIndexed} wider-save player records, but none can be shown until FM26 scout-knowledge visibility is mapped and validated. -

-
- ) : null} + {!players.length ?

No players match these filters

Clear one or more filters to return to the visibility-safe player set.

: null}
+ + {snapshot.status.backgroundPlayersIndexed > 0 ? ( +

{snapshot.status.backgroundPlayersIndexed} wider-save records are indexed but remain hidden until FM26 scouting visibility is validated.

+ ) : null}
); } diff --git a/src/components/startup-screen.tsx b/src/components/startup-screen.tsx index 103f275..984d7a8 100644 --- a/src/components/startup-screen.tsx +++ b/src/components/startup-screen.tsx @@ -24,11 +24,15 @@ export function StartupScreen({ onConnect: () => Promise; onEnter: (status: LiveConnectorStatus) => void; }) { - const desktopRuntime = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + const [desktopRuntime, setDesktopRuntime] = useState(false); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(null); const [loadStage, setLoadStage] = useState("detecting_fm26"); + useEffect(() => { + setDesktopRuntime("__TAURI_INTERNALS__" in window); + }, []); + useEffect(() => { if (!desktopRuntime) return; let remove: (() => void) | null = null; diff --git a/src/components/tactics-screen.tsx b/src/components/tactics-screen.tsx index 4f41102..4bd65d8 100644 --- a/src/components/tactics-screen.tsx +++ b/src/components/tactics-screen.tsx @@ -58,6 +58,7 @@ export function TacticsScreen({
Formation
{snapshot.tactic?.formation ?? "Unknown"}
Imported
{formatImportDate(snapshot.tacticImportedAt)}
Size
{formatFileSize(snapshot.tacticFileSize)}
+
Archive entries
{snapshot.tacticArchiveEntries?.join(", ") || "Unavailable"}
diff --git a/src/components/topbar.tsx b/src/components/topbar.tsx index af21152..c2659fc 100644 --- a/src/components/topbar.tsx +++ b/src/components/topbar.tsx @@ -43,8 +43,8 @@ export function Topbar({
-
Current squad{squad.length}
Fully known{squad.filter((player) => player.scoutKnowledge === "fully_known").length}
Visible attributes read{mappedAttributes}
-
PlayerPositionBest roleRatingFormContract / wageValueConditionStatusDetails
{positionGroups.map((group) => { const players = groups.get(group) ?? []; - if (players.length === 0) return null; - return ( -
-

{group}{players.length}

- {players.map((player) => )} -
- ); + if (!players.length) return null; + return

{group}{players.length}

{players.map((player) => )}
; })}
- -
-

Data coverage

-
-
Verified now

FM unique ID, age, date of birth, nationality, positions, preferred foot and visible 1–20 attributes.

-
Rating method

The circular score uses position-relevant visible attributes only. It is not hidden current ability and is not tactic fit without a decoded tactic.

-
Current tactic

{snapshot.tactic?.formation ?? "Import an FMF tactic to add formation-specific fit."}

-
Still unknown

Form, contracts, wages, valuation, fitness and squad status remain blank until their current-build relationships are validated.

-
-
); } diff --git a/src/components/player-face.tsx b/src/components/player-face.tsx index ad2b7cc..7604ecd 100644 --- a/src/components/player-face.tsx +++ b/src/components/player-face.tsx @@ -14,22 +14,20 @@ type PlayerFaceResult = { const faceCache = new Map(); -export function PlayerFace({ - playerId, - name, - size = "md", -}: { +export function PlayerFace({ playerId, name, size = "md", highResolution = false }: { playerId: string; name: string; size?: "sm" | "md" | "lg"; + highResolution?: boolean; }) { - const cacheKey = `${playerId}:${size === "sm" ? "icon" : "portrait"}`; + const useIcon = size === "sm" && !highResolution; + const cacheKey = `${playerId}:${useIcon ? "icon" : "portrait"}`; const [source, setSource] = useState(() => faceCache.get(cacheKey)); useEffect(() => { if (faceCache.has(cacheKey) || !("__TAURI_INTERNALS__" in window)) return; let active = true; - invoke("player_face_data", { playerId, icon: size === "sm" }) + invoke("player_face_data", { playerId, icon: useIcon }) .then((result) => { const next = result.found ? result.dataUrl : null; faceCache.set(cacheKey, next); @@ -39,10 +37,8 @@ export function PlayerFace({ faceCache.set(cacheKey, null); if (active) setSource(null); }); - return () => { - active = false; - }; - }, [cacheKey, playerId, size]); + return () => { active = false; }; + }, [cacheKey, playerId, useIcon]); return ( diff --git a/src/components/player-profile-screen.tsx b/src/components/player-profile-screen.tsx index c94d05e..59ece31 100644 --- a/src/components/player-profile-screen.tsx +++ b/src/components/player-profile-screen.tsx @@ -19,11 +19,13 @@ import type { LiveFootballSnapshot, LivePlayer } from "@/domain/adapters"; import { Button } from "@/components/ui/button"; import { ConfidenceRing } from "@/components/confidence-ring"; import { PlayerFace } from "@/components/player-face"; +import { ClubLogo } from "@/components/club-logo"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const technicalAttributes = ["Technique", "Dribbling", "Passing", "First Touch", "Finishing", "Crossing", "Long Shots"]; const mentalAttributes = ["Vision", "Decisions", "Composure", "Anticipation", "Work Rate", "Flair", "Teamwork"]; const physicalAttributes = ["Acceleration", "Pace", "Agility", "Balance", "Strength", "Stamina", "Natural Fitness"]; +const setPieceAttributes = ["Corners", "Free Kick Taking", "Penalty Taking", "Long Throws"]; function evidenceValue(player: LivePlayer, attribute: string) { const value = player.attributes?.[attribute]; @@ -41,18 +43,51 @@ function AttributeGroup({ title, names, player }: { title: string; names: string ); } +function PlayerPolygram({ player }: { player: LivePlayer }) { + const axes = [ + ["Technique", ["Technique", "First Touch", "Dribbling"]], + ["Creation", ["Passing", "Vision", "Flair"]], + ["Defending", ["Marking", "Tackling", "Positioning"]], + ["Movement", ["Acceleration", "Pace", "Agility"]], + ["Physical", ["Strength", "Stamina", "Jumping Reach"]], + ["Set pieces", setPieceAttributes], + ] as const; + const centre = 110; + const radius = 76; + const points = axes.map(([, names], index) => { + const values = names.flatMap((name) => typeof player.attributes?.[name] === "number" ? [player.attributes[name] as number] : []); + const value = values.length ? values.reduce((sum, item) => sum + item, 0) / values.length : 0; + const angle = -Math.PI / 2 + (index * Math.PI * 2) / axes.length; + const distance = radius * (value / 20); + return `${centre + Math.cos(angle) * distance},${centre + Math.sin(angle) * distance}`; + }).join(" "); + return ( +
+

Ability polygram

+ + {[.25, .5, .75, 1].map((scale) => { const angle = -Math.PI / 2 + (index * Math.PI * 2) / axes.length; return `${centre + Math.cos(angle) * radius * scale},${centre + Math.sin(angle) * radius * scale}`; }).join(" ")} className="polygram-grid" />)} + {axes.map(([label], index) => { const angle = -Math.PI / 2 + (index * Math.PI * 2) / axes.length; const x = centre + Math.cos(angle) * 98; const y = centre + Math.sin(angle) * 98; return {label}; })} + + +

Built only from visible attributes; missing axes remain at zero.

+
+ ); +} + export function PlayerProfileScreen({ player, snapshot, favorite, onToggleFavorite, onBack, + onOpenClub, }: { player: LivePlayer | null; snapshot: LiveFootballSnapshot; favorite: boolean; onToggleFavorite: () => void; onBack: () => void; + onOpenClub?: (clubId: string) => void; }) { if (!player) { return ( @@ -78,14 +113,14 @@ export function PlayerProfileScreen({
- +
Nationality{player.nationality ?? "Unknown"} - Club{club?.name ?? "Unknown"} + Age / DOB{player.age ?? "Unknown"}{player.dateOfBirth ? ` · ${player.dateOfBirth}` : ""} Position{player.positions.join(" / ") || "Unknown"} Preferred foot{player.preferredFoot ?? "Unknown"} @@ -97,7 +132,7 @@ export function PlayerProfileScreen({ - {["overview", "tactical", "attributes", "performance", "career", "notes"].map((value) => ( + {["overview", "tactical", "attributes", "performance", "career"].map((value) => ( {value === "tactical" ? "Tactical fit" : value[0].toUpperCase() + value.slice(1)} @@ -111,12 +146,11 @@ export function PlayerProfileScreen({

Scout summary

Live memory confirms {player.name} ({player.nationality ?? "nationality unknown"}, age {player.age ?? "unknown"}) belongs to {club?.name ?? "the current club"}. - The role evidence score uses only visible FM26 attributes for {player.bestRole ?? player.positions[0] ?? "the player’s position"}; hidden current and potential ability are never read.

Strengths

{player.strengths.length ? player.strengths.join(" · ") : "No visible evidence"}

Weaknesses

{player.weaknesses.length ? player.weaknesses.join(" · ") : "No visible evidence"}
-

Recommended next action

{snapshot.tactic ? "Review formation-specific fit." : "Import an FMF tactic before judging tactical fit."}
+

Recommended next action

{snapshot.tactic ? "Review live formation-specific fit." : "Keep scouting while the live tactic layout is validated."}
@@ -147,11 +181,13 @@ export function PlayerProfileScreen({ + @@ -238,10 +259,11 @@ export function PlayerProfileScreen({

In possession / out of possession evidence

-
In possession{snapshot.tactic ? player.bestRole ?? "Role unknown" : "Live tactic layout pending"}

{player.roleFit == null ? "Not enough visible evidence." : `${player.roleFit}/100 role evidence from known attributes.`}

-
Out of possession{snapshot.tactic ? "Role mapping pending" : "Live tactic layout pending"}

No out-of-possession role is inferred until the FM26 phase slot is validated.

+
Best mapped FM26 role{player.bestRole ?? "Role unknown"}

{player.roleFit == null ? "Not enough mapped evidence." : `${player.roleFit}/100 from position familiarity and mapped attributes.`}

+
Out of possession{snapshot.tactic ? "Tactic packet validation required" : "Live tactic layout pending"}

Out-of-possession slot roles are shown only when the FM26 tactic role packet validates.

Combined recommendation{player.recommendation?.minimum == null ? "Not enough evidence" : player.recommendation.minimum === player.recommendation.maximum ? player.recommendation.minimum : `${player.recommendation.minimum}–${player.recommendation.maximum}`}

{(player.recommendation?.completeness ?? 0) >= 95 ? "Exact score from complete visible evidence." : "Partial observations produce an interval, never a false exact score."}

+
diff --git a/src/components/tactical-board.tsx b/src/components/tactical-board.tsx index ac07a02..acdbab4 100644 --- a/src/components/tactical-board.tsx +++ b/src/components/tactical-board.tsx @@ -44,7 +44,7 @@ export function TacticalBoard({

{compact ? "System overview" : "Tactical board"}

- {!compact ?

Formation and selected XI are read from live FM26 memory. Roles and duties appear once their packed codes validate.

: null} + {!compact ?

Formation and selected XI are read from live FM26 memory. FM26 role and duty masks appear when the packet validates.

: null}
{(["In Possession", "Out of Possession", "Combined"] as const).map((item) => ( @@ -70,7 +70,7 @@ export function TacticalBoard({ ); })} diff --git a/src/components/tactics-screen.tsx b/src/components/tactics-screen.tsx index d4abf7e..63ef0d0 100644 --- a/src/components/tactics-screen.tsx +++ b/src/components/tactics-screen.tsx @@ -8,6 +8,9 @@ export function TacticsScreen({ snapshot }: { snapshot: LiveFootballSnapshot }) const ready = snapshot.tacticSource === "live-memory" && snapshot.tactic != null; const objectDetected = snapshot.status.liveMemoryTacticRead === "object_detected_unmapped"; const tacticObjectDetected = ready || objectDetected; + const rolesResolved = snapshot.tactic?.rolesResolved ?? 0; + const dutiesResolved = snapshot.tactic?.dutiesResolved ?? 0; + const roleDutyStatus = snapshot.tactic?.roleDutyDecoderStatus ?? "packet-not-validated"; return (
@@ -31,7 +34,8 @@ export function TacticsScreen({ snapshot }: { snapshot: LiveFootballSnapshot })
FM26 enum
{snapshot.tactic?.formationEnum ?? "Not available"}
Pitch layout
{snapshot.tactic?.layoutStatus === "exact-template" ? "Template mapped" : ready ? "Selected XI only" : "Not validated"}
Selected XI
{snapshot.tactic?.slots.length ? `${snapshot.tactic.slots.length} live slots` : "Not validated"}
-
Roles and duties
{ready ? "Decoder pending" : "Not validated"}
+
Roles and duties
{ready ? `${rolesResolved}/11 roles · ${dutiesResolved}/11 duties` : "Not validated"}
+
Role packet
{ready ? roleDutyStatus.replaceAll("-", " ") : "Not validated"}
Instructions
{snapshot.tactic?.teamInstructions.length ? "Available" : ready ? "Decoder pending" : "Not validated"}
@@ -40,7 +44,11 @@ export function TacticsScreen({ snapshot }: { snapshot: LiveFootballSnapshot })

{ready ? snapshot.tactic?.layoutStatus === "exact-template" - ? "The active FM26 formation template and selected XI have passed memory validation. Role/duty fit remains disabled until the packed role codes validate." + ? rolesResolved === 11 + ? dutiesResolved === 11 + ? "The active FM26 formation, selected XI, roles and duties have passed live memory validation." + : "The active FM26 formation, selected XI and role masks have passed live memory validation. Duty masks are still pending for this read." + : "The active FM26 formation template and selected XI have passed memory validation. Role/duty fit remains disabled until the packed role codes validate." : "The active FM26 formation code and selected XI are live. Exact pitch placement for this formation still needs template validation, so no role/duty fit is invented." : objectDetected ? "GlassScout found FM26’s live tactic manager, but the selected-slot block did not validate for this read. No tactic or fit score is invented." diff --git a/src/domain/adapters.ts b/src/domain/adapters.ts index e38e0a7..ee5f218 100644 --- a/src/domain/adapters.ts +++ b/src/domain/adapters.ts @@ -61,6 +61,8 @@ export type LivePlayer = { secondNationality?: string | null; positions: string[]; bestRole: string | null; + playableRoles?: PlayerRoleFit[]; + otherRoles?: PlayerRoleFit[]; currentAbility: number | null; potentialAbility: number | null; abilityScore?: number | null; @@ -105,6 +107,18 @@ export type LivePlayer = { knowledge?: Record>; }; +export type PlayerRoleFit = { + roleKey: string; + role: string; + shortRole: string; + roleIdMask: string; + positions: string[]; + score: number; + positionFit: number; + attributeFit: number | null; + evidence: string[]; +}; + export type FieldVisibility = "known" | "estimated" | "range" | "unknown"; export type KnowledgeField = { @@ -133,7 +147,11 @@ export type LiveTacticSlot = { playerId: string | null; position: string; role: string | null; + roleShort?: string | null; + roleMask?: string | null; duty: string | null; + dutyShort?: string | null; + dutyMask?: string | null; decoderStatus?: string; }; @@ -147,6 +165,12 @@ export type LiveTactic = { decoderStatus?: string; formationCode?: number; layoutStatus?: "exact-template" | "formation-name-only"; + roleDutyDecoderStatus?: string; + rolePacketPointer?: string | null; + rolePacketStride?: number | null; + rolePacketWidth?: number | null; + rolesResolved?: number; + dutiesResolved?: number; warnings?: string[]; }; From 20e6ba40faeb462b10b4b800665eaf4925057e04 Mon Sep 17 00:00:00 2001 From: TobiasTest22 Date: Thu, 2 Jul 2026 18:22:02 +0300 Subject: [PATCH 07/13] Bridge FM Dossier live save data --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 63 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/connector.rs | 58 +- src-tauri/src/fm_dossier.rs | 1067 +++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/tauri.conf.json | 2 +- src/components/dashboard-screen.tsx | 6 +- src/components/favorited-players-screen.tsx | 18 +- src/components/glass-scout-app.tsx | 51 +- src/components/player-profile-screen.tsx | 40 +- src/components/recruitment-screen.tsx | 22 +- src/components/startup-screen.tsx | 4 +- src/components/tactical-board.tsx | 3 +- src/domain/adapters.ts | 38 +- 16 files changed, 1335 insertions(+), 48 deletions(-) create mode 100644 src-tauri/src/fm_dossier.rs diff --git a/package-lock.json b/package-lock.json index 00c0d9b..8e8795e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "glassscout-fm26", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "glassscout-fm26", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/inter": "^5.2.8", diff --git a/package.json b/package.json index 3dc7125..e703863 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glassscout-fm26", - "version": "0.1.10", + "version": "0.1.11", "private": true, "scripts": { "dev": "next dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3ab0858..528c11d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -909,6 +921,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1281,9 +1305,10 @@ dependencies = [ [[package]] name = "glassscout-fm26" -version = "0.1.10" +version = "0.1.11" dependencies = [ "base64 0.22.1", + "rusqlite", "serde", "serde_json", "sha2", @@ -1414,7 +1439,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", ] [[package]] @@ -1434,6 +1468,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -2935,6 +2978,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.42.1" @@ -3428,7 +3485,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap 2.14.0", "log", "memchr", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 593fb40..816e26c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "glassscout-fm26" -version = "0.1.10" +version = "0.1.11" description = "Read-only desktop recruitment workspace for Football Manager 2026" authors = ["GlassScout"] edition = "2021" @@ -17,5 +17,6 @@ base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +rusqlite = { version = "0.32", features = ["bundled"] } tauri = { version = "2", features = [] } tauri-plugin-sql = { version = "2", features = ["sqlite"] } diff --git a/src-tauri/src/connector.rs b/src-tauri/src/connector.rs index 40eaa80..b479f7f 100644 --- a/src-tauri/src/connector.rs +++ b/src-tauri/src/connector.rs @@ -27,6 +27,7 @@ use crate::{ tactics::tactic_formation_template, validator, }, + fm_dossier, }; #[derive(Clone, Serialize)] @@ -163,6 +164,10 @@ pub fn load_active_save(app: tauri::AppHandle) -> ConnectorSnapshot { #[tauri::command] pub fn search_indexed_players(query: String) -> Vec { + let dossier_results = fm_dossier::search_players(&query, 500); + if !dossier_results.is_empty() { + return dossier_results; + } let normalized = query.trim().to_lowercase(); let Some(index) = PLAYER_DATABASE_INDEX.get() else { return Vec::new(); @@ -196,6 +201,10 @@ pub fn search_indexed_players(query: String) -> Vec { #[tauri::command] pub fn indexed_players_by_ids(player_ids: Vec) -> Vec { + let dossier_results = fm_dossier::players_by_ids(&player_ids); + if !dossier_results.is_empty() { + return dossier_results; + } let Some(index) = PLAYER_DATABASE_INDEX.get() else { return Vec::new(); }; @@ -209,6 +218,11 @@ pub fn indexed_players_by_ids(player_ids: Vec) -> Vec { .collect() } +#[tauri::command] +pub fn indexed_player_profile(player_id: String) -> Option { + fm_dossier::player_profile(&player_id) +} + fn indexed_player_json(record: &IndexedPlayerRecord) -> Value { json!({ "id": record.id, @@ -1069,13 +1083,37 @@ fn extract_live_data( object.remove("_season"); } } + let dossier_reference = fm_dossier::augment_live_players(&mut players); + let database_players_indexed = dossier_reference + .as_ref() + .map(|reference| database_players_indexed.max(reference.player_count)) + .unwrap_or(database_players_indexed); + let background_players_indexed = dossier_reference + .as_ref() + .map(|reference| { + reference + .player_count + .saturating_sub(players.len().try_into().unwrap_or_default()) + .max(background_players_indexed) + }) + .unwrap_or(background_players_indexed); + let database_index_status = if dossier_reference.is_some() { + "ready" + } else { + database_index_status + }; + let database_scope = if dossier_reference.is_some() { + "full-save-index" + } else { + database_scope + }; let tactic = tactic_manager_pointer .and_then(|pointer| extract_live_tactic(reader, pointer, &managed_tactic_records)); let mut warnings = vec![ "Managed-squad IDs, names, dates of birth, ages, nationality, positions, preferred foot and visible attributes are validated for this FM26 build. Hidden CA/PA is never read or scored.".to_string(), "FM26 role, duty and out-of-possession role catalogues are mapped from the current build metadata. Player playable-role scoring now uses mapped role metadata plus live attributes and position familiarity.".to_string(), "Form, match ratings, contract, wage, valuation, fitness and squad-status relationships are not yet validated for this build and remain Unknown.".to_string(), - "Own-squad players are fully known. Wider-save records remain hidden until FM26 scout-report visibility, ranges and confidence are validated.".to_string(), + "Own-squad players are fully known. Wider-save records are indexed; exact unknown data stays unavailable unless FM scout reports expose it.".to_string(), if tactic.is_some() { "Live FM26 tactic formation and selected XI slots are mapped from the active tactic manager. Role/duty slot packets are published only if their FM26 masks validate against the live formation.".to_string() } else { @@ -1084,15 +1122,27 @@ fn extract_live_data( "The FM26 shortlist collection is not mapped safely. GlassScout Favorites remains a local list resolved against live players.".to_string(), ]; if include_database_index && database_index_status == "ready" { - warnings.push(format!( - "{background_players_indexed} wider-save player records were indexed in memory. They remain hidden from the UI until FM scout-knowledge visibility can be validated." - )); + if dossier_reference.is_some() { + warnings.push(format!( + "{background_players_indexed} wider-save player records are available through the FM Dossier local save index. Scout-knowledge gates still decide what profile detail is shown." + )); + } else { + warnings.push(format!( + "{background_players_indexed} wider-save player records were indexed in memory. They remain hidden from the UI until FM scout-knowledge visibility can be validated." + )); + } } else if include_database_index { warnings.push( "The wider player database could not be indexed safely; only the managed squad is available." .to_string(), ); } + if let Some(reference) = &dossier_reference { + warnings.push(format!( + "FM Dossier local save index enriched missing contract, wage, value, condition, history and search fields from {}. This is a local read-only reference layer while native offsets are mapped.", + reference.path.display() + )); + } let clubs = vec![json!({ "id": club_id, "name": club_name, diff --git a/src-tauri/src/fm_dossier.rs b/src-tauri/src/fm_dossier.rs new file mode 100644 index 0000000..336c342 --- /dev/null +++ b/src-tauri/src/fm_dossier.rs @@ -0,0 +1,1067 @@ +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; + +use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Row}; +use serde_json::{json, Value}; + +use crate::fm26::{ + roles::evaluate_player_roles, + structs::{PLAYER_ATTRIBUTE_NAMES, POSITION_NAMES}, +}; + +#[derive(Clone, Debug)] +pub(crate) struct DossierReference { + pub(crate) player_count: u32, + pub(crate) path: PathBuf, +} + +#[derive(Clone, Debug)] +struct DossierPlayer { + uid: String, + name: String, + age: Option, + dob_year: Option, + dob_doy: Option, + foot_left: Option, + foot_right: Option, + height_cm: Option, + nationality: Option, + club_id: Option, + club_name: Option, + positions: Vec, + position_bytes: Vec, + attributes: HashMap, + scout_knowledge: &'static str, + scout_confidence: u8, + is_ours: bool, + best_position: Option, + contract_type: Option, + contract_expiry_packed: Option, + contract_start_packed: Option, + sign_date_packed: Option, + contract_remaining_text: Option, + wage: Option, + value: Option, + condition: Option, + match_sharpness: Option, + fatigue: Option, + injury_type: Option, + is_injured: Option, + squad_number: Option, + personality: Option, + traits_json: Option, + form_json: Option, + apps: Option, + minutes: Option, + goals: Option, + assists: Option, + avg_rating: Option, + xg: Option, + xa: Option, + shots: Option, + key_passes: Option, + chances_created: Option, + passes_att: Option, + passes_comp: Option, + tackles_att: Option, + tackles_won: Option, + interceptions: Option, + headers_att: Option, + headers_won: Option, + crosses_att: Option, + crosses_comp: Option, + dribbles: Option, + progressive_passes: Option, + clean_sheets: Option, + goals_conceded: Option, + career_apps: Option, + career_goals: Option, + career_league_apps: Option, + career_league_goals: Option, + transfer_listed: Option, + loan_listed: Option, + not_for_sale: Option, + top_ip_pct: Option, + top_oop_pct: Option, + top_ip_proj_pct: Option, + top_oop_proj_pct: Option, + eff_score: Option, +} + +pub(crate) fn augment_live_players(players: &mut [Value]) -> Option { + let path = latest_save_database()?; + let connection = open_readonly(&path).ok()?; + let player_count = connection + .query_row("select count(*) from player", [], |row| { + row.get::<_, i64>(0) + }) + .ok() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or_default(); + for player in players.iter_mut() { + let Some(uid) = player.get("id").and_then(Value::as_str) else { + continue; + }; + let Some(dossier) = read_player(&connection, uid).ok().flatten() else { + continue; + }; + merge_player(player, &dossier); + } + Some(DossierReference { player_count, path }) +} + +pub(crate) fn search_players(query: &str, limit: usize) -> Vec { + let Some(path) = latest_save_database() else { + return Vec::new(); + }; + let Ok(connection) = open_readonly(&path) else { + return Vec::new(); + }; + let normalized = query.trim(); + let sql = if normalized.is_empty() { + " + select p.uid + from player p + left join player_managed pm on pm.uid = p.uid + order by p.is_ours desc, coalesce(p.top_ip_pct, 0) desc, coalesce(p.common_name, p.native_full, '') asc + limit ?1 + " + } else { + " + select p.uid + from player p + left join club c on c.id = p.native_club_id + left join nation n on n.id = p.native_nation_id + where coalesce(p.common_name, '') like ?1 + or coalesce(p.native_full, '') like ?1 + or coalesce(c.name, '') like ?1 + or coalesce(n.name, '') like ?1 + order by p.is_ours desc, coalesce(p.knowledge_level, 0) desc, coalesce(p.top_ip_pct, 0) desc, coalesce(p.common_name, p.native_full, '') asc + limit ?2 + " + }; + let mut statement = match connection.prepare(sql) { + Ok(statement) => statement, + Err(_) => return Vec::new(), + }; + let uids = if normalized.is_empty() { + statement + .query_map([limit as i64], |row| row.get::<_, i64>(0)) + .map(|rows| rows.filter_map(Result::ok).collect::>()) + .unwrap_or_default() + } else { + let like = format!("%{normalized}%"); + statement + .query_map(params![like, limit as i64], |row| row.get::<_, i64>(0)) + .map(|rows| rows.filter_map(Result::ok).collect::>()) + .unwrap_or_default() + }; + + uids.into_iter() + .filter_map(|uid| read_player(&connection, &uid.to_string()).ok().flatten()) + .map(|player| indexed_player_json(&player)) + .collect() +} + +pub(crate) fn player_profile(player_id: &str) -> Option { + let path = latest_save_database()?; + let connection = open_readonly(&path).ok()?; + read_player(&connection, player_id) + .ok() + .flatten() + .map(|player| player_json(&player)) +} + +pub(crate) fn players_by_ids(player_ids: &[String]) -> Vec { + let Some(path) = latest_save_database() else { + return Vec::new(); + }; + let Ok(connection) = open_readonly(&path) else { + return Vec::new(); + }; + player_ids + .iter() + .filter_map(|id| read_player(&connection, id).ok().flatten()) + .map(|player| indexed_player_json(&player)) + .collect() +} + +fn latest_save_database() -> Option { + let appdata = env::var_os("APPDATA")?; + let saves = PathBuf::from(appdata) + .join("es.solbizz.fmdossier") + .join("saves"); + let mut candidates = fs::read_dir(saves) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("sqlite")) + .filter_map(|path| { + let modified = fs::metadata(&path) + .and_then(|metadata| metadata.modified()) + .ok()?; + Some((modified, path)) + }) + .collect::>(); + candidates.sort_by(|left, right| right.0.cmp(&left.0)); + candidates.into_iter().map(|(_, path)| path).next() +} + +fn open_readonly(path: &Path) -> rusqlite::Result { + Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) +} + +fn read_player(connection: &Connection, uid: &str) -> rusqlite::Result> { + let uid_i64 = match uid.parse::() { + Ok(uid) => uid, + Err(_) => return Ok(None), + }; + let mut statement = connection.prepare( + " + select + p.uid, + coalesce(nullif(p.common_name, ''), nullif(p.native_full, ''), trim(coalesce(p.native_first, '') || ' ' || coalesce(p.native_surname, ''))) as name, + p.age, p.dob_year, p.dob_doy, p.foot_left, p.foot_right, p.height_cm, + p.native_club_id, + c.name as club_name, + n.name as nation_name, + p.knowledge_level, + p.is_ours, + p.contract_type, + p.transfer_listed, + p.loan_listed, + p.not_for_sale, + p.value_native, + p.wage_native, + p.top_ip_pct, + p.top_oop_pct, + p.top_ip_proj_pct, + p.top_oop_proj_pct, + p.eff_score, + pm.contract_expiry_packed, + pm.contract_start_packed, + pm.sign_date_packed, + pm.contract_remaining_text, + pm.wage_managed, + pm.best_position_short, + pm.condition, + pm.is_injured, + pm.squad_number, + pm.personality, + pm.traits_json, + pm.match_sharpness, + pm.fatigue, + pm.injury_type, + coalesce(ps.apps, pm.overall_apps) as apps, + coalesce(ps.minutes, pm.overall_minutes) as minutes, + coalesce(ps.goals, pm.overall_goals) as goals, + coalesce(ps.assists, pm.overall_assists) as assists, + coalesce(ps.avg_rating, pm.overall_avg_rating) as avg_rating, + coalesce(ps.xg, pm.xg) as xg, + coalesce(ps.xa, pm.xa) as xa, + coalesce(ps.form_json, pm.form_json) as form_json, + ps.shots, + coalesce(ps.key_passes, pm.overall_key_passes) as key_passes, + coalesce(ps.chances_created, pm.overall_chances_created) as chances_created, + ps.passes_att, + ps.passes_comp, + ps.tackles_att, + ps.tackles_won, + ps.interceptions, + ps.headers_att, + ps.headers_won, + ps.crosses_att, + ps.crosses_comp, + ps.dribbles, + ps.progressive_passes, + ps.clean_sheets, + ps.goals_conceded, + ps.career_apps, + ps.career_goals, + ps.career_league_apps, + ps.career_league_goals + from player p + left join player_managed pm on pm.uid = p.uid + left join player_stats ps on ps.uid = p.uid + left join club c on c.id = p.native_club_id + left join nation n on n.id = p.native_nation_id + where p.uid = ?1 + ", + )?; + let mut player = statement + .query_row([uid_i64], |row| row_to_player(row)) + .optional()?; + if let Some(player) = player.as_mut() { + player.positions = read_positions(connection, uid_i64)?; + player.position_bytes = position_bytes(&player.positions); + if player.is_ours || player.scout_confidence >= 100 { + player.attributes = read_attributes(connection, uid_i64)?; + } + } + Ok(player) +} + +fn row_to_player(row: &Row<'_>) -> rusqlite::Result { + let uid: i64 = row.get("uid")?; + let knowledge_level = row + .get::<_, Option>("knowledge_level")? + .unwrap_or_default(); + let is_ours = row.get::<_, Option>("is_ours")?.unwrap_or_default() == 1; + let (scout_knowledge, scout_confidence) = if is_ours { + ("fully_known", 100) + } else if knowledge_level >= 5 { + ("fully_known", 100) + } else if knowledge_level >= 3 { + ("partly_known", 65) + } else if knowledge_level > 0 { + ("partly_known", 35) + } else { + ("unknown", 0) + }; + Ok(DossierPlayer { + uid: uid.to_string(), + name: row + .get::<_, Option>("name")? + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("FM player {uid}")), + age: row + .get::<_, Option>("age")? + .and_then(|value| u8::try_from(value).ok()), + dob_year: row + .get::<_, Option>("dob_year")? + .and_then(|value| u16::try_from(value).ok()), + dob_doy: row + .get::<_, Option>("dob_doy")? + .and_then(|value| u16::try_from(value).ok()), + foot_left: row.get("foot_left")?, + foot_right: row.get("foot_right")?, + height_cm: row.get("height_cm")?, + nationality: row.get("nation_name")?, + club_id: row + .get::<_, Option>("native_club_id")? + .map(|value| value.to_string()), + club_name: row.get("club_name")?, + positions: Vec::new(), + position_bytes: vec![0; POSITION_NAMES.len()], + attributes: HashMap::new(), + scout_knowledge, + scout_confidence, + is_ours, + best_position: row.get("best_position_short")?, + contract_type: row.get("contract_type")?, + contract_expiry_packed: row.get("contract_expiry_packed")?, + contract_start_packed: row.get("contract_start_packed")?, + sign_date_packed: row.get("sign_date_packed")?, + contract_remaining_text: row.get("contract_remaining_text")?, + wage: row + .get::<_, Option>("wage_managed")? + .or(row.get("wage_native")?), + value: row.get("value_native")?, + condition: row.get("condition")?, + match_sharpness: row.get("match_sharpness")?, + fatigue: row.get("fatigue")?, + injury_type: row.get("injury_type")?, + is_injured: row.get("is_injured")?, + squad_number: row.get("squad_number")?, + personality: row.get("personality")?, + traits_json: row.get("traits_json")?, + form_json: row.get("form_json")?, + apps: row.get("apps")?, + minutes: row.get("minutes")?, + goals: row.get("goals")?, + assists: row.get("assists")?, + avg_rating: row.get("avg_rating")?, + xg: row.get("xg")?, + xa: row.get("xa")?, + shots: row.get("shots")?, + key_passes: row.get("key_passes")?, + chances_created: row.get("chances_created")?, + passes_att: row.get("passes_att")?, + passes_comp: row.get("passes_comp")?, + tackles_att: row.get("tackles_att")?, + tackles_won: row.get("tackles_won")?, + interceptions: row.get("interceptions")?, + headers_att: row.get("headers_att")?, + headers_won: row.get("headers_won")?, + crosses_att: row.get("crosses_att")?, + crosses_comp: row.get("crosses_comp")?, + dribbles: row.get("dribbles")?, + progressive_passes: row.get("progressive_passes")?, + clean_sheets: row.get("clean_sheets")?, + goals_conceded: row.get("goals_conceded")?, + career_apps: row.get("career_apps")?, + career_goals: row.get("career_goals")?, + career_league_apps: row.get("career_league_apps")?, + career_league_goals: row.get("career_league_goals")?, + transfer_listed: row.get("transfer_listed")?, + loan_listed: row.get("loan_listed")?, + not_for_sale: row.get("not_for_sale")?, + top_ip_pct: row.get("top_ip_pct")?, + top_oop_pct: row.get("top_oop_pct")?, + top_ip_proj_pct: row.get("top_ip_proj_pct")?, + top_oop_proj_pct: row.get("top_oop_proj_pct")?, + eff_score: row.get("eff_score")?, + }) +} + +fn read_positions(connection: &Connection, uid: i64) -> rusqlite::Result> { + let mut statement = connection.prepare( + "select slot, familiarity from player_position where uid = ?1 order by familiarity desc, slot asc", + )?; + let mut positions = Vec::new(); + let rows = statement.query_map([uid], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + for row in rows.filter_map(Result::ok) { + if row.1 >= 15 { + positions.push(row.0); + } + } + if positions.is_empty() { + let mut fallback = connection.prepare( + "select slot from player_position where uid = ?1 order by familiarity desc, slot asc limit 1", + )?; + if let Some(slot) = fallback + .query_row([uid], |row| row.get::<_, String>(0)) + .optional()? + { + positions.push(slot); + } + } + Ok(positions) +} + +fn position_bytes(positions: &[String]) -> Vec { + let mut bytes = vec![1_u8; POSITION_NAMES.len()]; + for position in positions { + if let Some(index) = POSITION_NAMES + .iter() + .position(|name| *name == position.as_str()) + { + bytes[index] = 20; + } + } + bytes +} + +fn read_attributes(connection: &Connection, uid: i64) -> rusqlite::Result> { + let mut statement = + connection.prepare("select attr_key, true_value from player_attribute where uid = ?1")?; + let rows = statement.query_map([uid], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + Ok(rows + .filter_map(Result::ok) + .filter_map(|(key, value)| { + let name = normalize_attribute_key(&key)?; + let value = u8::try_from(value).ok()?.clamp(1, 20); + if hidden_attribute_name(name) { + return None; + } + Some((name.to_string(), value)) + }) + .collect()) +} + +fn normalize_attribute_key(key: &str) -> Option<&'static str> { + Some(match key { + "AerialReach" => "Aerial Reach", + "CommandOfArea" => "Command of Area", + "FirstTouch" => "First Touch", + "FreeKicks" => "Free Kick Taking", + "InjuryProneness" => "Injury Proneness", + "JumpingReach" => "Jumping Reach", + "LongShots" => "Long Shots", + "LongThrows" => "Long Throws", + "NaturalFitness" => "Natural Fitness", + "OffTheBall" => "Off the Ball", + "OneOnOnes" => "One on Ones", + "PenaltyTaking" => "Penalty Taking", + "RushingOut" => "Rushing Out", + "TendencyToPunch" => "Punching", + "WorkRate" => "Work Rate", + "LeftFoot" | "RightFoot" => return None, + other => PLAYER_ATTRIBUTE_NAMES + .iter() + .find(|name| name.replace(' ', "") == other || **name == other) + .copied()?, + }) +} + +fn hidden_attribute_name(name: &str) -> bool { + matches!( + name, + "Dirtiness" | "Consistency" | "Important Matches" | "Injury Proneness" | "Versatility" + ) +} + +fn indexed_player_json(player: &DossierPlayer) -> Value { + let role = role_summary(player); + json!({ + "id": player.uid, + "name": player.name, + "age": player.age, + "nationality": player.nationality, + "clubId": player.club_id, + "clubName": player.club_name, + "positions": player.positions, + "managedSquad": player.is_ours, + "visibility": if player.scout_confidence > 0 { "known" } else { "unknown" }, + "scoutKnowledge": player.scout_knowledge, + "scoutConfidence": player.scout_confidence, + "bestRole": role.0, + "roleFit": role.1, + "value": money_label(player.value, false), + "wage": money_label(player.wage, true), + "contractStatus": contract_label(player), + "averageRating": player.avg_rating, + "minutesPlayed": player.minutes, + "goals": player.goals, + "assists": player.assists, + }) +} + +fn player_json(player: &DossierPlayer) -> Value { + let role = role_summary(player); + let playable = if player.attributes.is_empty() { + Vec::new() + } else { + evaluate_player_roles(&player.position_bytes, &player.attributes).playable + }; + let other_roles = if player.attributes.is_empty() { + Vec::new() + } else { + evaluate_player_roles(&player.position_bytes, &player.attributes).secondary + }; + let role_reasoning = if player.attributes.is_empty() { + vec![ + "Profile is available from the FM Dossier save index, but attributes are hidden because scouting knowledge is insufficient.".to_string(), + ] + } else { + evaluate_player_roles(&player.position_bytes, &player.attributes).reasoning + }; + let condition = condition_label(player); + json!({ + "id": player.uid, + "name": player.name, + "age": player.age, + "dateOfBirth": fm_date_label(player.dob_year, player.dob_doy), + "nationality": player.nationality, + "secondNationality": null, + "positions": player.positions, + "bestRole": role.0, + "currentAbility": null, + "potentialAbility": null, + "abilityScore": role.1, + "form": form_label(player.avg_rating), + "averageRating": player.avg_rating, + "minutesPlayed": player.minutes, + "goals": player.goals, + "assists": player.assists, + "contractStatus": contract_label(player), + "value": money_label(player.value, false), + "wage": money_label(player.wage, true), + "squadImportance": player.squad_number.map(|number| format!("#{number}")), + "developmentTrend": null, + "tacticalFit": null, + "roleFit": role.1, + "playableRoles": playable, + "otherRoles": other_roles, + "preferredFoot": preferred_foot_label(player), + "heightCm": player.height_cm, + "strengths": attribute_extremes(&player.attributes, true), + "weaknesses": attribute_extremes(&player.attributes, false), + "clubId": player.club_id, + "clubName": player.club_name, + "transferInterest": interest_label(player), + "loanInterest": if player.loan_listed == Some(1) { Some("Loan listed") } else { None }, + "transferAvailable": player.transfer_listed.map(|value| value == 1), + "loanAvailable": player.loan_listed.map(|value| value == 1), + "attributes": player.attributes, + "per90": per90(player), + "rawStats": raw_stats(player), + "careerTotals": career_totals(player), + "formHistory": player.form_json, + "traits": player.traits_json, + "inPossessionFit": player.top_ip_pct.map(round_percent), + "outOfPossessionFit": player.top_oop_pct.map(round_percent), + "projectedInPossessionFit": player.top_ip_proj_pct.map(round_percent), + "projectedOutOfPossessionFit": player.top_oop_proj_pct.map(round_percent), + "efficiencyScore": player.eff_score, + "scoutKnowledge": player.scout_knowledge, + "scoutConfidence": player.scout_confidence, + "lastScoutedDate": null, + "reportReliability": if player.scout_confidence >= 60 { "FM Dossier knowledge index" } else { "Unknown" }, + "bestCalculatedPosition": player.best_position, + "truePrice": null, + "fairPriceRange": null, + "valuationLabel": "unavailable", + "valuationReasoning": ["Value and wage are read from FM Dossier's local save index when available; no hidden CA/PA is used."], + "retrainingSuggestion": null, + "roleReasoning": role_reasoning, + "riskLevel": if player.is_injured == Some(1) { "high" } else { "unknown" }, + "marketValueAmount": player.value, + "personality": player.personality, + "condition": condition, + "matchSharpness": player.match_sharpness, + "fatigue": player.fatigue, + "contractStartDate": player.contract_start_packed.and_then(unpack_fm_dossier_date), + "signDate": player.sign_date_packed.and_then(unpack_fm_dossier_date), + "contractRemaining": player.contract_remaining_text, + "recommendation": { + "minimum": role.1, + "maximum": role.1, + "completeness": player.scout_confidence, + "label": if player.scout_confidence >= 60 { "FM Dossier local evidence" } else { "identity and public database evidence only" } + }, + "knowledge": {} + }) +} + +fn merge_player(player: &mut Value, dossier: &DossierPlayer) { + let Some(object) = player.as_object_mut() else { + return; + }; + let role = role_summary(dossier); + if object.get("age").is_none_or(Value::is_null) { + object.insert("age".to_string(), json!(dossier.age)); + } + if object.get("dateOfBirth").is_none_or(Value::is_null) { + object.insert( + "dateOfBirth".to_string(), + json!(fm_date_label(dossier.dob_year, dossier.dob_doy)), + ); + } + if object.get("nationality").is_none_or(Value::is_null) { + object.insert("nationality".to_string(), json!(dossier.nationality)); + } + if object + .get("positions") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + object.insert("positions".to_string(), json!(dossier.positions)); + } + if object + .get("attributes") + .and_then(Value::as_object) + .is_none_or(serde_json::Map::is_empty) + && !dossier.attributes.is_empty() + { + object.insert("attributes".to_string(), json!(dossier.attributes)); + } + if object.get("scoutKnowledge").and_then(Value::as_str) != Some("fully_known") { + object.insert("scoutKnowledge".to_string(), json!(dossier.scout_knowledge)); + } + let existing_confidence = object + .get("scoutConfidence") + .and_then(Value::as_u64) + .unwrap_or_default(); + if existing_confidence < u64::from(dossier.scout_confidence) { + object.insert( + "scoutConfidence".to_string(), + json!(dossier.scout_confidence), + ); + } + if object.get("contractStatus").is_none_or(Value::is_null) { + object.insert("contractStatus".to_string(), json!(contract_label(dossier))); + } + if object.get("wage").is_none_or(Value::is_null) { + object.insert("wage".to_string(), json!(money_label(dossier.wage, true))); + } + if object.get("value").is_none_or(Value::is_null) { + object.insert( + "value".to_string(), + json!(money_label(dossier.value, false)), + ); + } + object.insert("condition".to_string(), json!(condition_label(dossier))); + object.insert("averageRating".to_string(), json!(dossier.avg_rating)); + object.insert("minutesPlayed".to_string(), json!(dossier.minutes)); + object.insert("goals".to_string(), json!(dossier.goals)); + object.insert("assists".to_string(), json!(dossier.assists)); + object.insert("form".to_string(), json!(form_label(dossier.avg_rating))); + object.insert( + "preferredFoot".to_string(), + json!(preferred_foot_label(dossier)), + ); + object.insert("heightCm".to_string(), json!(dossier.height_cm)); + object.insert("matchSharpness".to_string(), json!(dossier.match_sharpness)); + object.insert("fatigue".to_string(), json!(dossier.fatigue)); + object.insert( + "squadImportance".to_string(), + json!(dossier.squad_number.map(|number| format!("#{number}"))), + ); + object.insert( + "transferAvailable".to_string(), + json!(dossier.transfer_listed.map(|value| value == 1)), + ); + object.insert( + "loanAvailable".to_string(), + json!(dossier.loan_listed.map(|value| value == 1)), + ); + object.insert( + "transferInterest".to_string(), + json!(interest_label(dossier)), + ); + object.insert("clubName".to_string(), json!(dossier.club_name)); + object.insert( + "dossierReference".to_string(), + json!("fm-dossier-local-save-index"), + ); + if !dossier.attributes.is_empty() { + let evaluation = evaluate_player_roles(&dossier.position_bytes, &dossier.attributes); + if object + .get("playableRoles") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + object.insert("playableRoles".to_string(), json!(evaluation.playable)); + } + if object + .get("otherRoles") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + object.insert("otherRoles".to_string(), json!(evaluation.secondary)); + } + if object + .get("strengths") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + object.insert( + "strengths".to_string(), + json!(attribute_extremes(&dossier.attributes, true)), + ); + } + if object + .get("weaknesses") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + object.insert( + "weaknesses".to_string(), + json!(attribute_extremes(&dossier.attributes, false)), + ); + } + } + if object.get("roleFit").and_then(Value::as_u64).is_none() { + object.insert("roleFit".to_string(), json!(role.1)); + } + if role.0.is_some() || object.get("bestRole").is_none_or(Value::is_null) { + object.insert("bestRole".to_string(), json!(role.0)); + } + object.insert( + "inPossessionFit".to_string(), + json!(dossier.top_ip_pct.map(round_percent)), + ); + object.insert( + "outOfPossessionFit".to_string(), + json!(dossier.top_oop_pct.map(round_percent)), + ); + object.insert( + "projectedInPossessionFit".to_string(), + json!(dossier.top_ip_proj_pct.map(round_percent)), + ); + object.insert( + "projectedOutOfPossessionFit".to_string(), + json!(dossier.top_oop_proj_pct.map(round_percent)), + ); + object.insert("efficiencyScore".to_string(), json!(dossier.eff_score)); + object.insert("per90".to_string(), per90(dossier)); + object.insert("rawStats".to_string(), raw_stats(dossier)); + object.insert("careerTotals".to_string(), career_totals(dossier)); + object.insert("formHistory".to_string(), json!(dossier.form_json)); + object.insert("traits".to_string(), json!(dossier.traits_json)); + object.insert( + "contractStartDate".to_string(), + json!(dossier + .contract_start_packed + .and_then(unpack_fm_dossier_date)), + ); + object.insert( + "signDate".to_string(), + json!(dossier.sign_date_packed.and_then(unpack_fm_dossier_date)), + ); + object.insert( + "contractRemaining".to_string(), + json!(dossier.contract_remaining_text), + ); +} + +fn role_summary(player: &DossierPlayer) -> (Option, Option) { + if let Some(role) = player + .best_position + .as_ref() + .map(|position| position_role_label(position).to_string()) + { + return (Some(role), player.top_ip_pct.map(round_percent)); + } + if !player.attributes.is_empty() { + if let Some(best) = evaluate_player_roles(&player.position_bytes, &player.attributes).best { + return (Some(best.role.to_string()), Some(best.score)); + } + } + (None, player.top_ip_pct.map(round_percent)) +} + +fn position_role_label(position: &str) -> &'static str { + match position { + "GK" => "Goalkeeper", + "DC" => "Central Defender", + "DL" | "DR" | "WBL" | "WBR" => "Full-Back", + "DM" => "Defensive Midfielder", + "MC" => "Central Midfielder", + "ML" | "MR" | "AML" | "AMR" => "Winger", + "AMC" => "Attacking Midfielder", + "ST" => "Centre Forward", + _ => "Natural Position", + } +} + +fn round_percent(value: f64) -> u8 { + value.round().clamp(0.0, 100.0) as u8 +} + +fn money_label(value: Option, weekly: bool) -> Option { + let value = value.filter(|value| *value > 0)?; + let suffix = if weekly { " p/w" } else { "" }; + Some(format!("€{}{suffix}", grouped_number(value))) +} + +fn grouped_number(value: i64) -> String { + let text = value.abs().to_string(); + let mut output = String::new(); + for (index, ch) in text.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + output.push(','); + } + output.push(ch); + } + let mut output = output.chars().rev().collect::(); + if value < 0 { + output.insert(0, '-'); + } + output +} + +fn contract_label(player: &DossierPlayer) -> Option { + let mut parts = Vec::new(); + if let Some(contract_type) = player + .contract_type + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + parts.push(contract_type.clone()); + } + if let Some(date) = player + .contract_expiry_packed + .and_then(unpack_fm_dossier_date) + { + parts.push(format!("expires {date}")); + } + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +fn unpack_fm_dossier_date(value: i64) -> Option { + if value <= 0 { + return None; + } + let year = value / 131_072; + let day = (value % 131_072) / 256; + if !(1900..=2100).contains(&year) || !(1..=366).contains(&day) { + return None; + } + fm_date_label(Some(year as u16), Some(day as u16)) +} + +fn fm_date_label(year: Option, day_of_year: Option) -> Option { + let year = year?; + let mut remaining = day_of_year?; + if remaining == 0 { + return None; + } + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let lengths: [u16; 12] = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + for (month, length) in lengths.into_iter().enumerate() { + if remaining <= length { + return Some(format!("{year:04}-{:02}-{:02}", month + 1, remaining)); + } + remaining -= length; + } + None +} + +fn condition_label(player: &DossierPlayer) -> Option { + if let Some(injury) = player + .injury_type + .as_ref() + .filter(|value| !value.is_empty()) + { + return Some(injury.clone()); + } + if player.is_injured == Some(1) { + return Some("Injured".to_string()); + } + player + .condition + .map(|condition| format!("{}%", condition.clamp(0, 100))) +} + +fn preferred_foot_label(player: &DossierPlayer) -> Option<&'static str> { + let left = player.foot_left.unwrap_or_default(); + let right = player.foot_right.unwrap_or_default(); + if left <= 0 && right <= 0 { + return None; + } + if (left - right).abs() <= 3 && left >= 10 && right >= 10 { + Some("Both") + } else if left > right { + Some("Left") + } else { + Some("Right") + } +} + +fn form_label(avg_rating: Option) -> Option { + avg_rating.map(|rating| format!("{rating:.2}")) +} + +fn interest_label(player: &DossierPlayer) -> Option<&'static str> { + if player.not_for_sale == Some(1) { + Some("Not for sale") + } else if player.transfer_listed == Some(1) { + Some("Transfer listed") + } else if player.loan_listed == Some(1) { + Some("Loan listed") + } else { + None + } +} + +fn per90(player: &DossierPlayer) -> Value { + let minutes = player + .minutes + .filter(|minutes| *minutes > 0) + .map(|minutes| minutes as f64); + let per = |value: Option| -> Option { + Some(((value? / minutes?) * 90.0 * 100.0).round() / 100.0) + }; + json!({ + "xgPer90": per(player.xg), + "xaPer90": per(player.xa), + "goalsPer90": per(player.goals.map(|value| value as f64)), + "assistsPer90": per(player.assists.map(|value| value as f64)), + "shotsPer90": per(player.shots.map(|value| value as f64)), + "keyPassesPer90": per(player.key_passes.map(|value| value as f64)), + "chancesCreatedPer90": per(player.chances_created.map(|value| value as f64)), + "passesAttemptedPer90": per(player.passes_att.map(|value| value as f64)), + "passesCompletedPer90": per(player.passes_comp.map(|value| value as f64)), + "tacklesAttemptedPer90": per(player.tackles_att.map(|value| value as f64)), + "tacklesWonPer90": per(player.tackles_won.map(|value| value as f64)), + "interceptionsPer90": per(player.interceptions.map(|value| value as f64)), + "headersWonPer90": per(player.headers_won.map(|value| value as f64)), + "crossesCompletedPer90": per(player.crosses_comp.map(|value| value as f64)), + "dribblesPer90": per(player.dribbles.map(|value| value as f64)), + "progressivePassesPer90": per(player.progressive_passes.map(|value| value as f64)) + }) +} + +fn raw_stats(player: &DossierPlayer) -> Value { + json!({ + "apps": player.apps, + "minutes": player.minutes, + "goals": player.goals, + "assists": player.assists, + "xg": player.xg, + "xa": player.xa, + "shots": player.shots, + "keyPasses": player.key_passes, + "chancesCreated": player.chances_created, + "passesAttempted": player.passes_att, + "passesCompleted": player.passes_comp, + "tacklesAttempted": player.tackles_att, + "tacklesWon": player.tackles_won, + "interceptions": player.interceptions, + "headersAttempted": player.headers_att, + "headersWon": player.headers_won, + "crossesAttempted": player.crosses_att, + "crossesCompleted": player.crosses_comp, + "dribbles": player.dribbles, + "progressivePasses": player.progressive_passes, + "cleanSheets": player.clean_sheets, + "goalsConceded": player.goals_conceded + }) +} + +fn career_totals(player: &DossierPlayer) -> Value { + json!({ + "careerApps": player.career_apps, + "careerGoals": player.career_goals, + "careerLeagueApps": player.career_league_apps, + "careerLeagueGoals": player.career_league_goals + }) +} + +fn attribute_extremes(attributes: &HashMap, strongest: bool) -> Vec { + let mut values = attributes + .iter() + .map(|(name, value)| (name.clone(), *value)) + .collect::>(); + if strongest { + values.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + } else { + values.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0))); + } + values + .into_iter() + .take(3) + .map(|(name, value)| format!("{name} {value}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_fm_dossier_bridge_reads_current_save_when_available() { + if latest_save_database().is_none() { + return; + } + + let results = search_players("", 10); + assert!( + !results.is_empty(), + "FM Dossier save index should expose players" + ); + + let first_id = results[0]["id"].as_str().expect("player id"); + let profile = player_profile(first_id).expect("indexed profile"); + assert_eq!(profile["id"].as_str(), Some(first_id)); + assert!(profile["name"] + .as_str() + .is_some_and(|name| !name.is_empty())); + assert!(profile["positions"].as_array().is_some()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b4d946..71e7d11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod connector; mod data; mod fm26; +mod fm_dossier; mod graphics; mod mapping_lab; mod player_face; @@ -33,6 +34,7 @@ pub fn run() { connector::load_active_save, connector::search_indexed_players, connector::indexed_players_by_ids, + connector::indexed_player_profile, mapping_lab::mapping_lab_status, mapping_lab::mapping_lab_capture, mapping_lab::mapping_lab_compare, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ec6f367..6b30f13 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GlassScout FM26", - "version": "0.1.10", + "version": "0.1.11", "identifier": "com.glassscout.fm26", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/dashboard-screen.tsx b/src/components/dashboard-screen.tsx index b1f49c4..1b532d9 100644 --- a/src/components/dashboard-screen.tsx +++ b/src/components/dashboard-screen.tsx @@ -99,7 +99,7 @@ export function DashboardScreen({ }, { icon: ShieldCheck, - title: `${squad.length} managed-club players visibility-safe`, + title: `${squad.length} managed-club players mapped`, detail: "IDs, ages, nations, positions, feet and visible attributes are live; unsupported fields remain Unknown.", action: "Open squad", screen: "Squad" as Screen, @@ -116,10 +116,10 @@ export function DashboardScreen({

- No visibility-safe external targets yet + Search the indexed player database

{snapshot.status.backgroundPlayersIndexed - ? `${snapshot.status.backgroundPlayersIndexed} wider-save records are indexed but hidden until club knowledge is mapped.` + ? `${snapshot.status.backgroundPlayersIndexed} wider-save records are searchable. Unknown details stay unknown until scout knowledge exists.` : "Load the active save to build the wider player index."}

diff --git a/src/components/favorited-players-screen.tsx b/src/components/favorited-players-screen.tsx index 298f648..cf5618e 100644 --- a/src/components/favorited-players-screen.tsx +++ b/src/components/favorited-players-screen.tsx @@ -14,11 +14,11 @@ type FavoriteSort = "name" | "age" | "roleFit" | "tacticalFit"; function indexedFavorite(result: IndexedPlayerSearchResult): LivePlayer { return { - id: result.id, name: result.name, age: null, nationality: null, positions: result.positions, - bestRole: null, currentAbility: null, potentialAbility: null, form: null, averageRating: null, - minutesPlayed: null, goals: null, assists: null, contractStatus: null, value: null, wage: null, - squadImportance: null, developmentTrend: null, tacticalFit: null, roleFit: null, strengths: [], - weaknesses: [], clubId: null, transferInterest: null, loanInterest: null, transferAvailable: null, + id: result.id, name: result.name, age: result.age ?? null, nationality: result.nationality ?? null, positions: result.positions, + bestRole: result.bestRole ?? null, currentAbility: null, potentialAbility: null, form: result.averageRating == null ? null : result.averageRating.toFixed(2), averageRating: result.averageRating ?? null, + minutesPlayed: result.minutesPlayed ?? null, goals: result.goals ?? null, assists: result.assists ?? null, contractStatus: result.contractStatus ?? null, value: result.value ?? null, wage: result.wage ?? null, + squadImportance: null, developmentTrend: null, tacticalFit: null, roleFit: result.roleFit ?? null, strengths: [], + weaknesses: [], clubId: result.clubId ?? null, clubName: result.clubName ?? null, transferInterest: null, loanInterest: null, transferAvailable: null, loanAvailable: null, attributes: {}, scoutKnowledge: result.scoutKnowledge, scoutConfidence: result.scoutConfidence, preferredFoot: null, }; @@ -31,6 +31,7 @@ export function FavoritedPlayersScreen({ onRefresh, onToggleFavorite, onUpdateNote, + onOpenPlayer, }: { snapshot: LiveFootballSnapshot; favorites: FavoriteRecord[]; @@ -38,6 +39,7 @@ export function FavoritedPlayersScreen({ onRefresh: () => Promise; onToggleFavorite: (playerId: string) => void; onUpdateNote: (playerId: string, note: string) => void; + onOpenPlayer: (playerId: string) => void; }) { const [query, setQuery] = useState(""); const [sort, setSort] = useState("name"); @@ -92,14 +94,14 @@ export function FavoritedPlayersScreen({
ComparePlayerKnowledgeRole & fitForm & marketShortlist noteRemove
{visible.map(({ player, note }) => ( -
- toggleCompare(player.id)} /> +
onOpenPlayer(player.id)}> + event.stopPropagation()} onChange={() => toggleCompare(player.id)} />
{player.name}{player.age ?? "Age unknown"} · {player.nationality ?? "Nation unknown"} · {player.positions.join(" / ") || "Position unknown"}
{player.scoutConfidence == null ? : }{(player.scoutKnowledge ?? "unknown").replaceAll("_", " ")}{player.scoutConfidence == null ? "Confidence unknown" : `${player.scoutConfidence}% confidence`}
{player.bestRole ?? "Role not evaluated"}{player.roleFit == null ? "Fit unknown" : `${player.roleFit}% role fit`}
{player.form ?? "Form unknown"}{player.value ?? "Value unknown"} · {player.wage ?? "Wage unknown"} · {player.contractStatus ?? "Contract unknown"}
onUpdateNote(player.id, event.target.value)} /> - +
))}
diff --git a/src/components/glass-scout-app.tsx b/src/components/glass-scout-app.tsx index 868acdd..eaca432 100644 --- a/src/components/glass-scout-app.tsx +++ b/src/components/glass-scout-app.tsx @@ -16,10 +16,12 @@ import { ClubProfileScreen } from "@/components/club-profile-screen"; import { TooltipProvider } from "@/components/ui/tooltip"; import { fm26LiveAdapter, + indexedPlayerProfile, searchIndexedPlayers, type IndexedPlayerSearchResult, type LiveConnectorStatus, type LiveFootballSnapshot, + type LivePlayer, } from "@/domain/adapters"; import { toggleFavorite, updateFavoriteNote, type FavoriteRecord } from "@/domain/live-data"; @@ -81,6 +83,31 @@ const initialSnapshot: LiveFootballSnapshot = { dataWarnings: [], }; +function meaningfulEntries(player: LivePlayer) { + return Object.fromEntries( + Object.entries(player).filter(([, value]) => { + if (value == null) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }), + ) as Partial; +} + +function mergeIndexedProfile(existing: LivePlayer | undefined, profile: LivePlayer): LivePlayer { + if (!existing) return profile; + return { + ...existing, + ...meaningfulEntries(profile), + attributes: Object.keys(profile.attributes ?? {}).length ? profile.attributes : existing.attributes, + per90: Object.keys(profile.per90 ?? {}).length ? profile.per90 : existing.per90, + strengths: profile.strengths?.length ? profile.strengths : existing.strengths, + weaknesses: profile.weaknesses?.length ? profile.weaknesses : existing.weaknesses, + playableRoles: profile.playableRoles?.length ? profile.playableRoles : existing.playableRoles, + otherRoles: profile.otherRoles?.length ? profile.otherRoles : existing.otherRoles, + }; +} + export function GlassScoutApp() { const [mode, setMode] = useState<"fm26" | null>(null); const [screen, setScreen] = useState("Dashboard"); @@ -130,10 +157,26 @@ export function GlassScoutApp() { const togglePlayerFavorite = (playerId: string) => setFavorites((current) => toggleFavorite(current, playerId)); const updatePlayerNote = (playerId: string, note: string) => setFavorites((current) => updateFavoriteNote(current, playerId, note)); - const openPlayer = (playerId: string) => { + const loadIndexedPlayerProfile = useCallback(async (playerId: string) => { + try { + const profile = await indexedPlayerProfile(playerId); + if (!profile) return; + setSnapshot((current) => { + const existingIndex = current.players.findIndex((player) => player.id === profile.id); + if (existingIndex === -1) return { ...current, players: [...current.players, profile] }; + const players = [...current.players]; + players[existingIndex] = mergeIndexedProfile(players[existingIndex], profile); + return { ...current, players }; + }); + } catch { + // The profile screen will keep its clean unavailable state if the local save index cannot resolve this player. + } + }, []); + const openPlayer = useCallback((playerId: string) => { setSelectedPlayerId(playerId); setScreen("Player Profile"); - }; + void loadIndexedPlayerProfile(playerId); + }, [loadIndexedPlayerProfile]); const openClub = (clubId: string) => { setSelectedClubId(clubId); setScreen("Club Profile"); @@ -153,7 +196,7 @@ export function GlassScoutApp() { screen === "Squad" ? : screen === "Tactical Board" ? : screen === "Scout Room" ? : - screen === "Shortlist" ? : + screen === "Shortlist" ? : screen === "Player Profile" ? ( player.id === selectedPlayerId) ?? null} @@ -183,7 +226,7 @@ export function GlassScoutApp() {
Search results
{globalClubs.map((club) => )} - {globalIndexed.map((result) => )} + {globalIndexed.map((result) => )} {!globalClubs.length && !globalIndexed.length ?

No indexed player or mapped team matches “{search}”.

: null}
) : null} diff --git a/src/components/player-profile-screen.tsx b/src/components/player-profile-screen.tsx index f47b90d..3878461 100644 --- a/src/components/player-profile-screen.tsx +++ b/src/components/player-profile-screen.tsx @@ -33,6 +33,19 @@ function evidenceValue(player: LivePlayer, attribute: string) { return typeof value === "number" ? String(value) : "Unknown"; } +function metricLabel(value: string) { + return value + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\bXg\b/i, "xG") + .replace(/\bXa\b/i, "xA") + .replace(/^./, (letter) => letter.toUpperCase()); +} + +function metricValue(label: string, value: unknown) { + if (typeof value !== "number") return "Unknown"; + return value.toFixed(label.toLowerCase().includes("per 90") ? 2 : 0); +} + function AttributeGroup({ title, names, player }: { title: string; names: string[]; player: LivePlayer }) { return (
@@ -114,12 +127,13 @@ export function PlayerProfileScreen({ return (
-

Player unavailable

This player is not present in the latest visibility-safe dataset.

+

Player unavailable

This player is not present in the latest live or indexed FM26 data.

); } const club = player.clubId ? snapshot.clubs.find((item) => item.id === player.clubId) : null; + const clubName = club?.name ?? player.clubName; const mappedAttributeCount = Object.values(player.attributes ?? {}).filter((value) => typeof value === "number").length; const knownEvidence = player.scoutConfidence ?? Math.min(100, Math.round((mappedAttributeCount / 47) * 100)); const unknownEvidence = 100 - knownEvidence; @@ -141,7 +155,7 @@ export function PlayerProfileScreen({
Nationality{player.nationality ?? "Unknown"} - + Age / DOB{player.age ?? "Unknown"}{player.dateOfBirth ? ` · ${player.dateOfBirth}` : ""} Position{player.positions.join(" / ") || "Unknown"} Preferred foot{player.preferredFoot ?? "Unknown"} @@ -166,7 +180,7 @@ export function PlayerProfileScreen({

Scout summary

- Live memory confirms {player.name} ({player.nationality ?? "nationality unknown"}, age {player.age ?? "unknown"}) belongs to {club?.name ?? "the current club"}. + GlassScout identifies {player.name} ({player.nationality ?? "nationality unknown"}, age {player.age ?? "unknown"}) at {clubName ?? "an unmapped club"}.

Strengths

{player.strengths.length ? player.strengths.join(" · ") : "No visible evidence"}
@@ -240,7 +254,7 @@ export function PlayerProfileScreen({
{Array.from({ length: 10 }, (_, index) => )}
Current form{player.averageRating?.toFixed(2) ?? "Unknown"}
- AppearancesUnknown + Minutes{player.minutesPlayed ?? "Unknown"} Goals{player.goals ?? "Unknown"} Assists{player.assists ?? "Unknown"}
@@ -260,7 +274,8 @@ export function PlayerProfileScreen({

In possession / out of possession evidence

Best mapped FM26 role{player.bestRole ?? "Role unknown"}

{player.roleFit == null ? "Not enough mapped evidence." : `${player.roleFit}/100 from position familiarity and mapped attributes.`}

-
Out of possession{snapshot.tactic ? "Tactic packet validation required" : "Live tactic layout pending"}

Out-of-possession slot roles are shown only when the FM26 tactic role packet validates.

+
In possession{player.inPossessionFit == null ? "Unknown" : `${player.inPossessionFit}/100`}

Mapped from the local FM Dossier role-phase index when available.

+
Out of possession{player.outOfPossessionFit == null ? "Unknown" : `${player.outOfPossessionFit}/100`}

Mapped separately from the out-of-possession role-phase index when available.

Combined recommendation{player.recommendation?.minimum == null ? "Not enough evidence" : player.recommendation.minimum === player.recommendation.maximum ? player.recommendation.minimum : `${player.recommendation.minimum}–${player.recommendation.maximum}`}

{(player.recommendation?.completeness ?? 0) >= 95 ? "Exact score from complete visible evidence." : "Partial observations produce an interval, never a false exact score."}

@@ -283,14 +298,25 @@ export function PlayerProfileScreen({

Performance and per 90

{Object.entries({ "Average rating": player.averageRating, Minutes: player.minutesPlayed, Goals: player.goals, Assists: player.assists, ...player.per90 }).map(([label, value]) => ( -
{label}{typeof value === "number" ? value.toFixed(label.includes("90") ? 2 : 0) : "Unknown"}
+
{metricLabel(label)}{metricValue(metricLabel(label), value)}
))}

Only competition and per-90 values read from club-visible FM26 structures appear here.

-

Career history is not mapped for this build

No placeholder seasons or appearances are shown.

+
+

Career history

Mapped totals
+
+ {Object.entries(player.careerTotals ?? {}).map(([label, value]) => ( +
{metricLabel(label)}{metricValue(metricLabel(label), value)}
+ ))} +
Signed{player.signDate ?? "Unknown"}
+
Contract start{player.contractStartDate ?? "Unknown"}
+
Remaining{player.contractRemaining ?? "Unknown"}
+
+

Season-by-season timeline is shown only when that structure is mapped; these totals come from the local FM26 save index.

+
diff --git a/src/components/recruitment-screen.tsx b/src/components/recruitment-screen.tsx index 7381877..8feabb1 100644 --- a/src/components/recruitment-screen.tsx +++ b/src/components/recruitment-screen.tsx @@ -14,15 +14,15 @@ import { Input } from "@/components/ui/input"; type SortKey = "name" | "role" | "knowledge" | "interest" | "recommendation"; type SortDirection = "asc" | "desc"; -const interestedLabels = new Set(["Very interested", "Interested", "Slightly interested", "Only loan possible"]); +const interestedLabels = new Set(["Very interested", "Interested", "Slightly interested", "Only loan possible", "Transfer listed", "Loan listed"]); function indexedPlayer(result: IndexedPlayerSearchResult): LivePlayer { return { - id: result.id, name: result.name, age: null, nationality: null, positions: result.positions, - bestRole: null, currentAbility: null, potentialAbility: null, form: null, averageRating: null, - minutesPlayed: null, goals: null, assists: null, contractStatus: null, value: null, wage: null, - squadImportance: null, developmentTrend: null, tacticalFit: null, roleFit: null, strengths: [], - weaknesses: [], clubId: null, transferInterest: null, loanInterest: null, transferAvailable: null, + id: result.id, name: result.name, age: result.age ?? null, nationality: result.nationality ?? null, positions: result.positions, + bestRole: result.bestRole ?? null, currentAbility: null, potentialAbility: null, form: result.averageRating == null ? null : result.averageRating.toFixed(2), averageRating: result.averageRating ?? null, + minutesPlayed: result.minutesPlayed ?? null, goals: result.goals ?? null, assists: result.assists ?? null, contractStatus: result.contractStatus ?? null, value: result.value ?? null, wage: result.wage ?? null, + squadImportance: null, developmentTrend: null, tacticalFit: null, roleFit: result.roleFit ?? null, strengths: [], + weaknesses: [], clubId: result.clubId ?? null, clubName: result.clubName ?? null, transferInterest: null, loanInterest: null, transferAvailable: null, loanAvailable: null, attributes: {}, scoutKnowledge: result.scoutKnowledge, scoutConfidence: result.scoutConfidence, preferredFoot: null, }; @@ -66,7 +66,7 @@ export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onTo onOpenPlayer: (playerId: string) => void; }) { const [query, setQuery] = useState(""); - const [interestedOnly, setInterestedOnly] = useState(true); + const [interestedOnly, setInterestedOnly] = useState(false); const [position, setPosition] = useState("All"); const [knowledge, setKnowledge] = useState("All"); const [indexed, setIndexed] = useState([]); @@ -95,7 +95,8 @@ export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onTo const normalized = query.trim().toLowerCase(); const result = allPlayers.filter((player) => { const club = player.clubId ? clubById.get(player.clubId) : null; - const matchesQuery = !normalized || [player.name, club?.name, player.nationality, ...player.positions].filter(Boolean).some((item) => item?.toLowerCase().includes(normalized)); + const clubName = club?.name ?? player.clubName; + const matchesQuery = !normalized || [player.name, clubName, player.nationality, ...player.positions].filter(Boolean).some((item) => item?.toLowerCase().includes(normalized)); return matchesQuery && (!interestedOnly || interestedLabels.has(interestLabel(player))) && (position === "All" || player.positions.includes(position)) @@ -151,9 +152,10 @@ export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onTo const favorite = favoriteIds.has(player.id); const rec = recommendation(player); const hasLiveDossier = liveById.has(player.id); + const clubName = club?.name ?? player.clubName; return ( -
hasLiveDossier && onOpenPlayer(player.id)}> -
{player.name}{player.age ?? "Age unknown"} · {player.nationality ?? "Nation unknown"} · {player.positions.join(" / ") || "Position unknown"} · FM ID {player.id}{club ? : null}{club?.name ?? "Club not mapped"}
+
onOpenPlayer(player.id)}> +
{player.name}{player.age ?? "Age unknown"} · {player.nationality ?? "Nation unknown"} · {player.positions.join(" / ") || "Position unknown"} · FM ID {player.id}{club ? : null}{clubName ?? "Club not mapped"}
{player.bestRole ?? "Not evaluated"}{player.tacticalFit == null ? "Live tactic required" : `${player.tacticalFit}% tactic fit`}
{knowledgeLabel(player)}{player.scoutConfidence == null ? "Confidence unknown" : `${player.scoutConfidence}% confidence`}
{interestLabel(player)}{interestLabel(player) === "Unknown" ? "Not mapped for this build" : "From FM26"}
diff --git a/src/components/startup-screen.tsx b/src/components/startup-screen.tsx index 64482c6..b5ee522 100644 --- a/src/components/startup-screen.tsx +++ b/src/components/startup-screen.tsx @@ -13,7 +13,7 @@ const loadStageLabels: Record = { reading_managed_club: "Reading managed club…", loading_managed_squad: "Loading managed squad…", indexing_player_database: "Indexing wider player database…", - building_visibility_index: "Building visibility-safe index…", + building_visibility_index: "Building scouting-knowledge index…", ready: "Ready", }; @@ -67,7 +67,7 @@ export function StartupScreen({

{desktopRuntime ? "Load your active FM26 save" : "Install GlassScout for Windows"}

{desktopRuntime - ? "Open FM26 and load your save, then start a read-only scan. GlassScout loads your club and squad first, then indexes the wider player database behind strict visibility gates." + ? "Open FM26 and load your save, then start a read-only scan. GlassScout loads your club and squad first, then indexes the wider player database with scouting-knowledge limits." : "GlassScout reads the active FM26 game through its installed Windows desktop connector."}

{desktopRuntime ? ( diff --git a/src/components/tactical-board.tsx b/src/components/tactical-board.tsx index acdbab4..c671b2d 100644 --- a/src/components/tactical-board.tsx +++ b/src/components/tactical-board.tsx @@ -66,11 +66,12 @@ export function TacticalBoard({ const player = playerById(snapshot.players, slot.playerId); const [left, top] = coordinatesForSlot(slot, index); const fit = player?.roleFit; + const roleLabel = slot.roleShort ?? slot.role ?? "Role not decoded"; return ( ); })} diff --git a/src/domain/adapters.ts b/src/domain/adapters.ts index ee5f218..a30636b 100644 --- a/src/domain/adapters.ts +++ b/src/domain/adapters.ts @@ -82,12 +82,21 @@ export type LivePlayer = { strengths: string[]; weaknesses: string[]; clubId: string | null; + clubName?: string | null; transferInterest: string | null; loanInterest: string | null; transferAvailable: boolean | null; loanAvailable: boolean | null; attributes?: Record; per90?: Record; + inPossessionFit?: number | null; + outOfPossessionFit?: number | null; + projectedInPossessionFit?: number | null; + projectedOutOfPossessionFit?: number | null; + matchSharpness?: number | null; + fatigue?: number | null; + efficiencyScore?: number | null; + dossierReference?: string | null; scoutKnowledge?: "fully_known" | "partly_known" | "unknown" | "needs_scouting" | "missing_data"; scoutConfidence?: number | null; lastScoutedDate?: string | null; @@ -103,6 +112,14 @@ export type LivePlayer = { marketValueAmount?: number | null; personality?: string | null; condition?: string | null; + heightCm?: number | null; + rawStats?: Record; + careerTotals?: Record; + formHistory?: string | null; + traits?: string | null; + contractStartDate?: string | null; + signDate?: string | null; + contractRemaining?: string | null; recommendation?: RecommendationEvidence; knowledge?: Record>; }; @@ -309,11 +326,24 @@ export const fm26LiveAdapter: FootballDataAdapter = { export type IndexedPlayerSearchResult = { id: string; name: string; + age?: number | null; + nationality?: string | null; + clubId?: string | null; + clubName?: string | null; positions: string[]; managedSquad: boolean; visibility: "known" | "unknown"; - scoutKnowledge: "fully_known" | "unknown"; + scoutKnowledge: "fully_known" | "partly_known" | "unknown"; scoutConfidence: number; + bestRole?: string | null; + roleFit?: number | null; + value?: string | null; + wage?: string | null; + contractStatus?: string | null; + averageRating?: number | null; + minutesPlayed?: number | null; + goals?: number | null; + assists?: number | null; }; export async function searchIndexedPlayers(query: string): Promise { @@ -328,6 +358,12 @@ export async function indexedPlayersByIds(playerIds: string[]): Promise("indexed_players_by_ids", { playerIds }); } +export async function indexedPlayerProfile(playerId: string): Promise { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("indexed_player_profile", { playerId }); +} + export type MappingLabStatus = { enabled: boolean; readOnly: boolean; From 0fb7dd517bba77dcfd8feea8334379f6cf6fee5b Mon Sep 17 00:00:00 2001 From: TobiasTest22 Date: Thu, 2 Jul 2026 19:34:15 +0300 Subject: [PATCH 08/13] Redesign Scout Room and improve live tactic scoring --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/fm_dossier.rs | 15 +- src-tauri/tauri.conf.json | 6 +- src/app/globals.css | 890 ++++++++++++++++ src/components/favorited-players-screen.tsx | 2 +- src/components/glass-scout-app.tsx | 11 +- src/components/recruitment-screen.tsx | 1056 +++++++++++++++++-- src/components/settings-screen.tsx | 2 +- src/components/tactical-board.tsx | 167 ++- src/components/tactics-screen.tsx | 4 +- src/domain/adapters.ts | 13 + 14 files changed, 2037 insertions(+), 139 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e8795e..b9a681d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "glassscout-fm26", - "version": "0.1.11", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "glassscout-fm26", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/inter": "^5.2.8", diff --git a/package.json b/package.json index e703863..2c5a76b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glassscout-fm26", - "version": "0.1.11", + "version": "0.1.12", "private": true, "scripts": { "dev": "next dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 528c11d..367edf6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1305,7 +1305,7 @@ dependencies = [ [[package]] name = "glassscout-fm26" -version = "0.1.11" +version = "0.1.12" dependencies = [ "base64 0.22.1", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 816e26c..ded4edb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "glassscout-fm26" -version = "0.1.11" +version = "0.1.12" description = "Read-only desktop recruitment workspace for Football Manager 2026" authors = ["GlassScout"] edition = "2021" diff --git a/src-tauri/src/fm_dossier.rs b/src-tauri/src/fm_dossier.rs index 336c342..3b86ada 100644 --- a/src-tauri/src/fm_dossier.rs +++ b/src-tauri/src/fm_dossier.rs @@ -300,7 +300,7 @@ fn read_player(connection: &Connection, uid: &str) -> rusqlite::Result= 100 { + if player.is_ours || player.scout_confidence > 0 { player.attributes = read_attributes(connection, uid_i64)?; } } @@ -520,10 +520,23 @@ fn indexed_player_json(player: &DossierPlayer) -> Value { "value": money_label(player.value, false), "wage": money_label(player.wage, true), "contractStatus": contract_label(player), + "contractRemaining": player.contract_remaining_text, "averageRating": player.avg_rating, "minutesPlayed": player.minutes, "goals": player.goals, "assists": player.assists, + "transferInterest": interest_label(player), + "loanInterest": if player.loan_listed == Some(1) { Some("Loan listed") } else { None }, + "transferAvailable": player.transfer_listed.map(|value| value == 1), + "loanAvailable": player.loan_listed.map(|value| value == 1), + "per90": per90(player), + "rawStats": raw_stats(player), + "inPossessionFit": player.top_ip_pct.map(round_percent), + "outOfPossessionFit": player.top_oop_pct.map(round_percent), + "projectedInPossessionFit": player.top_ip_proj_pct.map(round_percent), + "projectedOutOfPossessionFit": player.top_oop_proj_pct.map(round_percent), + "efficiencyScore": player.eff_score, + "marketValueAmount": player.value, }) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6b30f13..93fa781 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GlassScout FM26", - "version": "0.1.11", + "version": "0.1.12", "identifier": "com.glassscout.fm26", "build": { "beforeDevCommand": "npm run dev", @@ -13,8 +13,8 @@ "windows": [ { "title": "GlassScout FM26", - "width": 1440, - "height": 900, + "width": 1800, + "height": 960, "minWidth": 1100, "minHeight": 720, "resizable": true, diff --git a/src/app/globals.css b/src/app/globals.css index 463343a..b2cbcf8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -738,7 +738,13 @@ body{background:#f8fbfc;color:var(--navy);font-size:14px} .pitch-box{position:absolute;left:25%;width:50%;height:22%;border:1px solid #b8dfd9}.pitch-box-top{top:-1px}.pitch-box-bottom{bottom:-1px}.pitch-goal{position:absolute;left:41%;width:18%;height:8%;border:1px solid #b8dfd9}.pitch-goal-top{top:-1px}.pitch-goal-bottom{bottom:-1px} .formation-card{position:absolute;right:10px;top:10px;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:85px;padding:9px;border:1px solid #d7e2ea;border-radius:9px;background:rgba(255,255,255,.88)}.formation-card strong{font-size:16px}.formation-card span{color:var(--slate);font-size:9px} .pitch-player{position:absolute;display:flex;flex-direction:column;align-items:center;gap:3px;width:130px;border:0;background:transparent;transform:translate(-50%,-50%);color:var(--navy)}.pitch-player:disabled{opacity:1}.pitch-player strong{max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px}.pitch-player small{color:#5a6e89;font-size:9px}.fit-ring{--fit:0%;display:grid;place-items:center;width:39px;height:39px;border-radius:50%;background:radial-gradient(circle,#fff 58%,transparent 60%),conic-gradient(var(--mint) var(--fit),#e5edf2 0);font-size:11px;font-weight:750}.fit-ring[data-known=false]{background:radial-gradient(circle,#fff 58%,transparent 60%),conic-gradient(#dfe8ee 100%,#dfe8ee 0)} +.pitch-player.selected .fit-ring{box-shadow:0 0 0 4px rgba(10,163,142,.14)} +.pitch-player.selected strong{color:#06786c} .pitch-empty{position:absolute;left:50%;top:51%;display:flex;flex-direction:column;align-items:center;width:min(400px,60%);padding:22px;border:1px solid rgba(216,227,235,.9);border-radius:12px;background:rgba(255,255,255,.9);text-align:center;transform:translate(-50%,-50%);box-shadow:0 8px 30px rgba(40,70,90,.06)}.pitch-empty>svg{width:25px;color:#bd8424}.pitch-empty strong{margin-top:8px;font-size:14px}.pitch-empty span{margin:5px 0 13px;color:var(--slate);font-size:10px;line-height:1.45} +.tactic-slot-panel{margin:0 20px 20px;border:1px solid #d8e3eb;border-radius:10px;background:#fbfdfd;overflow:hidden} +.tactic-slot-panel>header{display:flex;align-items:center;gap:9px;min-height:45px;padding:0 14px;border-bottom:1px solid #e5edf2}.tactic-slot-panel>header svg{width:16px;color:var(--mint)}.tactic-slot-panel>header strong{font-size:12px}.tactic-slot-panel>header span{margin-left:auto;color:var(--slate);font-size:10px} +.slot-current-player{display:grid;grid-template-columns:1fr 120px auto;gap:10px;align-items:center;padding:12px 14px;border-bottom:1px solid #e5edf2}.slot-current-player span{display:flex;flex-direction:column;gap:4px}.slot-current-player small,.slot-alternatives>small{color:var(--slate);font-size:8px;text-transform:uppercase}.slot-current-player strong{font-size:13px}.slot-current-player button{height:31px;padding:0 11px;border:1px solid #c9dbe4;border-radius:8px;background:#fff;color:#0b496f;font-size:9px;font-weight:750} +.slot-alternatives{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));gap:8px;padding:12px 14px}.slot-alternatives>small{grid-column:1/-1}.slot-alternatives button{display:grid;grid-template-columns:1fr 28px;gap:4px;padding:10px;border:1px solid #dce8ef;border-radius:8px;background:#fff;text-align:left}.slot-alternatives button strong{font-size:9px}.slot-alternatives button span{color:var(--slate);font-size:8px}.slot-alternatives button b{grid-row:1/3;grid-column:2;align-self:center;justify-self:center;display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:#e2f7f1;color:#04796c;font-size:9px}.slot-alternatives p{grid-column:1/-1;margin:0;color:var(--slate);font-size:10px} .recruitment-pulse{display:flex;flex-direction:column;min-height:508px}.recruitment-pulse-empty{display:flex;align-items:flex-start;gap:16px;margin:3px 16px;padding:22px 18px;border:1px solid #dbe5ec;border-radius:10px;background:#fbfdfd}.target-avatar{display:grid;place-items:center;width:60px;height:60px;border:1px solid #bfe0dc;border-radius:50%;color:#36506f}.target-avatar svg{width:25px}.recruitment-pulse-empty div{display:flex;flex-direction:column;gap:8px}.recruitment-pulse-empty strong{font-size:14px}.recruitment-pulse-empty p{margin:0;color:var(--slate);font-size:11px;line-height:1.55}.recruitment-pulse>button{margin:auto 16px 16px;height:43px;border-color:#078c7c;color:#06746a} .department-briefing,.squad-health{min-height:250px}.department-briefing>div,.squad-health>div{margin:0 15px 15px;border:1px solid #dbe5ec;border-radius:8px;overflow:hidden}.briefing-row{display:grid;grid-template-columns:38px 1fr auto 18px;align-items:center;gap:10px;width:100%;min-height:66px;padding:9px 12px;border:0;border-top:1px solid #dbe5ec;background:#fbfdfd;text-align:left}.briefing-row:first-child{border-top:0}.briefing-icon,.health-icon{display:grid;place-items:center;width:34px;height:34px;border:1px solid #cbe4e0;border-radius:50%;background:#f0f8f7;color:#087f72}.briefing-icon svg,.health-icon svg{width:17px}.briefing-row>span:nth-child(2){display:flex;flex-direction:column;gap:5px}.briefing-row strong{font-size:11px}.briefing-row small{color:var(--slate);font-size:9px}.briefing-row b{padding:6px 10px;border:1px solid #edb852;border-radius:8px;color:#a86a06;font-size:9px;font-weight:600}.briefing-row>svg{width:15px;color:#5c6e86} @@ -874,3 +880,887 @@ button:active, .performance-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:0 14px 14px}.evidence-caption{padding:0 14px 14px;color:var(--slate);font-size:9px} .polygram-panel svg{display:block;width:100%;max-height:220px;padding:4px 16px}.polygram-grid{fill:none;stroke:#dce6ea;stroke-width:1}.polygram-shape{fill:rgba(25,169,143,.22);stroke:#19a98f;stroke-width:2}.polygram-panel text{fill:#66778c;font-size:7px}.polygram-panel>p{padding:0 14px 12px;color:var(--slate);font-size:8px} @media(max-width:1250px){.scout-room-toolbar{grid-template-columns:1fr 1fr}.club-squad-grid{grid-template-columns:repeat(2,1fr)}} + +/* 0.1.12 Scout Room database redesign from Tobias' reference */ +.scout-room-reference{ + --scout-bg:#f8fbfd; + --scout-panel:#ffffff; + --scout-border:#dce8ef; + --scout-muted:#718199; + --scout-navy:#061947; + --scout-green:#2fbda0; + --scout-green-dark:#008e78; + --scout-blue:#3a77ff; + --scout-orange:#f3a51d; + --scout-purple:#9d69ff; + min-height:calc(100vh - 68px); + color:var(--scout-navy); + background:linear-gradient(180deg,#fbfdff 0%,var(--scout-bg) 100%); +} +.app-canvas:has(.scout-room-reference){ + grid-template-columns:64px minmax(0,1fr); +} +.app-canvas:has(.scout-room-reference) .app-sidebar{ + width:64px; + align-items:center; + padding:14px 8px 18px; +} +.app-canvas:has(.scout-room-reference) .brand{ + justify-content:center; + padding:0 0 22px; +} +.app-canvas:has(.scout-room-reference) .brand-copy, +.app-canvas:has(.scout-room-reference) .nav-item span, +.app-canvas:has(.scout-room-reference) .sidebar-footer span, +.app-canvas:has(.scout-room-reference) .sidebar-footer svg{ + display:none; +} +.app-canvas:has(.scout-room-reference) .brand-mark{ + width:32px; + height:32px; +} +.app-canvas:has(.scout-room-reference) .brand-mark:before{width:42px} +.app-canvas:has(.scout-room-reference) .brand-mark:after{height:42px} +.app-canvas:has(.scout-room-reference) .nav-list{gap:8px} +.app-canvas:has(.scout-room-reference) .nav-item{ + width:44px; + height:44px; + justify-content:center; + padding:0; + border-radius:8px; +} +.app-canvas:has(.scout-room-reference) .nav-item svg{width:18px;height:18px} +.app-canvas:has(.scout-room-reference) .sidebar-footer{ + display:flex; + justify-content:center; + width:100%; + padding:12px 0 0; +} +.app-canvas:has(.scout-room-reference) .sidebar-collapse{ + margin:12px auto 0; + width:34px; + height:34px; +} +.scout-room-reference button, +.scout-room-reference select, +.scout-room-reference input{box-shadow:none!important} +.scout-room-hero{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:20px; + margin-bottom:16px; +} +.scout-room-hero>div:first-child{display:grid;grid-template-columns:auto auto;align-items:center;gap:7px 12px} +.scout-room-hero h1{margin:0;font-size:30px;line-height:1;font-weight:780;letter-spacing:-.05em;color:var(--scout-navy)} +.scout-room-hero p{grid-column:1/-1;margin:0;color:var(--scout-muted);font-size:11px} +.indexed-pill{ + display:inline-flex; + align-items:center; + gap:7px; + height:30px; + padding:0 12px; + border:1px solid var(--scout-border); + border-radius:8px; + background:#fff; + color:#567087; + font-size:10px; + font-weight:700; +} +.indexed-pill svg{width:14px;color:var(--scout-green)} +.scout-view-actions{display:flex;align-items:center;gap:10px} +.scout-view-actions .dark-action{ + border-color:#071b3e; + background:#071b3e; + color:#fff; +} +.scout-filter-strip{ + display:grid; + grid-template-columns:minmax(260px,1.75fr) repeat(9,minmax(112px,.72fr)) minmax(155px,.95fr) 126px; + gap:9px; + align-items:stretch; + padding:12px; + border:1px solid var(--scout-border); + border-radius:12px; + background:#fff; +} +.scout-search-box{ + position:relative; + display:flex; + align-items:center; + min-height:48px; + border:1px solid var(--scout-border); + border-radius:8px; + background:#fff; +} +.scout-search-box svg{position:absolute;left:13px;width:17px;color:#536b89} +.scout-search-box input{ + height:100%; + min-height:48px; + padding:0 44px 0 38px; + border:0; + background:transparent; + color:var(--scout-navy); + font-size:10px; +} +.scout-search-box kbd{ + position:absolute; + right:10px; + top:50%; + transform:translateY(-50%); + padding:3px 6px; + border-radius:6px; + background:#f2f6fa; + color:#7f8da1; + font-size:8px; + font-family:inherit; +} +.scout-filter-tile{ + position:relative; + display:flex; + flex-direction:column; + justify-content:center; + min-height:48px; + padding:8px 28px 7px 12px; + border:1px solid var(--scout-border); + border-radius:8px; + background:#fff; + overflow:hidden; +} +.scout-filter-tile span{ + margin-bottom:5px; + color:#536987; + font-size:8px; + font-weight:700; +} +.scout-filter-tile b{ + overflow:hidden; + color:var(--scout-navy); + font-size:10px; + font-weight:760; + text-overflow:ellipsis; + white-space:nowrap; +} +.scout-filter-tile select{ + position:absolute; + inset:0; + width:100%; + opacity:0; + cursor:pointer; +} +.scout-filter-tile svg{ + position:absolute; + right:9px; + top:50%; + width:13px; + transform:translateY(-50%); + color:#58708d; +} +.joining-toggle{ + display:flex; + align-items:center; + justify-content:center; + gap:10px; + padding:0 11px; + border:1px solid var(--scout-border); + border-radius:8px; + background:#fff; + color:var(--scout-navy); + font-size:10px; + font-weight:760; +} +.joining-toggle button{ + position:relative; + width:31px; + height:17px; + padding:0; + border:0; + border-radius:999px; + background:#d7e3eb; +} +.joining-toggle button i{ + position:absolute; + top:2px; + left:2px; + width:13px; + height:13px; + border-radius:50%; + background:#fff; + transition:transform .18s ease; +} +.joining-toggle button.on{background:var(--scout-green)} +.joining-toggle button.on i{transform:translateX(14px)} +.joining-toggle svg{width:13px;color:#718199} +.more-filter-button{ + min-height:48px; + border-color:var(--scout-border)!important; + color:#16385f!important; + font-size:10px!important; + font-weight:760!important; +} +.scout-metric-strip{ + display:grid; + grid-template-columns:repeat(8,minmax(128px,1fr)); + gap:9px; + margin:14px 0 13px; +} +.scout-metric-card{ + position:relative; + min-height:104px; + padding:16px 15px 12px; + border:1px solid var(--scout-border); + border-radius:9px; + background:#fff; + text-align:left; + overflow:hidden; +} +.scout-metric-card.active, +.scout-metric-card:hover{border-color:#b7dcd5;background:#fbfffe} +.metric-title{ + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + color:#102449; + font-size:9px; + font-weight:760; +} +.metric-title i{ + display:grid; + place-items:center; + width:24px; + height:24px; + border-radius:50%; + background:#eef7f6; + color:var(--scout-green); +} +.metric-title svg{width:13px} +.scout-metric-card strong{display:block;margin-top:9px;font-size:20px;line-height:1;font-weight:800;letter-spacing:-.04em} +.scout-metric-card small{display:block;margin-top:6px;color:var(--scout-muted);font-size:8px} +.metric-line{ + position:absolute; + left:15px; + right:15px; + bottom:12px; + height:20px; +} +.metric-line:before{ + content:""; + position:absolute; + inset:5px 0 0; + background:linear-gradient(130deg,transparent 0 7%,#18a2c2 7% 9%,transparent 9% 21%,#18a2c2 21% 23%,transparent 23% 43%,#18a2c2 43% 45%,transparent 45% 58%,#18a2c2 58% 60%,transparent 60% 75%,#18a2c2 75% 77%,transparent 77% 100%); + opacity:.9; +} +.metric-donut{ + position:absolute; + right:18px; + bottom:16px; + width:48px; + height:48px; + border-radius:50%; + background:conic-gradient(var(--scout-green) 0 245deg,#d7e0e8 245deg 360deg); +} +.metric-donut:after{ + content:""; + position:absolute; + inset:9px; + border-radius:50%; + background:#fff; +} +.metric-bars{ + position:absolute; + right:14px; + bottom:13px; + display:flex; + align-items:end; + gap:5px; + height:34px; +} +.metric-bars i{ + width:4px; + border-radius:999px; + background:var(--scout-green); +} +.metric-bars i:nth-child(1){height:8px}.metric-bars i:nth-child(2){height:15px}.metric-bars i:nth-child(3){height:11px}.metric-bars i:nth-child(4){height:23px}.metric-bars i:nth-child(5){height:18px}.metric-bars i:nth-child(6){height:31px}.metric-bars i:nth-child(7){height:13px} +.scout-database-layout{ + display:grid; + grid-template-columns:212px minmax(0,1fr); + gap:12px; + align-items:start; +} +.scout-database-layout.filters-collapsed{grid-template-columns:38px minmax(0,1fr)} +.scout-left-filters{ + min-height:620px; + padding:13px; + border:1px solid var(--scout-border); + border-radius:10px; + background:#fff; +} +.hide-filters{ + display:flex; + align-items:center; + justify-content:space-between; + width:100%; + padding:7px 0; + border:0; + background:transparent; + color:#546a85; + font-size:8px; + font-weight:760; +} +.hide-filters svg,.show-filters-tab svg{width:13px} +.clear-filters{ + margin:11px 0 10px; + border:0; + background:transparent; + color:#8593a5; + font-size:7px; + font-weight:800; +} +.saved-filter-block, +.side-filter-section{ + padding:10px 0; + border-top:1px solid #edf3f6; +} +.saved-filter-block h3, +.side-filter-section h3{ + display:flex; + align-items:center; + justify-content:space-between; + margin:0 0 9px; + color:#65758c; + font-size:8px; + font-weight:800; + text-transform:uppercase; +} +.saved-filter-block h3 button, +.side-filter-section h3 button{ + border:0; + background:transparent; + color:var(--scout-green-dark); + font-size:8px; +} +.saved-filter{ + display:grid; + grid-template-columns:23px 1fr; + gap:2px 8px; + width:100%; + margin-bottom:4px; + padding:9px 8px; + border:1px solid transparent; + border-radius:8px; + background:transparent; + text-align:left; +} +.saved-filter span{ + grid-row:1/3; + display:grid; + place-items:center; + width:20px; + height:20px; + border-radius:50%; + background:#eef4f7; + color:#31506f; +} +.saved-filter svg{width:12px} +.saved-filter b{font-size:8px;color:#203655} +.saved-filter small{font-size:7px;color:#6c7b91} +.saved-filter.active, +.saved-filter:hover{border-color:#d4ebe6;background:#effaf7} +.saved-filter.active span{background:#dff7f0;color:var(--scout-green-dark)} +.checkbox-filter{ + display:grid; + grid-template-columns:16px 1fr auto; + align-items:center; + gap:8px; + min-height:25px; + color:#1d3151; + font-size:8px; +} +.checkbox-filter input{accent-color:var(--scout-green)} +.checkbox-filter small{color:#8190a3;font-size:7px} +.range-stack input[type=range]{ + width:100%; + accent-color:var(--scout-green); +} +.range-caption{ + display:flex; + justify-content:space-between; + margin:-3px 0 5px; + color:#7a8a9f; + font-size:7px; +} +.side-select{ + display:flex; + flex-direction:column; + gap:6px; + margin-top:10px; + color:#65758c; + font-size:8px; + font-weight:800; + text-transform:uppercase; +} +.side-select select{ + height:34px; + border:1px solid var(--scout-border); + border-radius:7px; + background:#fff; + color:var(--scout-navy); + font-size:9px; + padding:0 10px; + text-transform:none; +} +.show-filters-tab{ + writing-mode:vertical-rl; + display:flex; + align-items:center; + gap:8px; + min-height:160px; + padding:12px 8px; + border:1px solid var(--scout-border); + border-radius:10px; + background:#fff; + color:#4f6680; + font-size:9px; + font-weight:800; +} +.scout-table-panel{ + min-width:0; + border:1px solid var(--scout-border); + border-radius:10px; + background:#fff; + overflow:hidden; +} +.scout-table-toolbar{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + min-height:54px; + padding:0 13px; + border-bottom:1px solid #e8f0f4; + color:#617189; + font-size:9px; +} +.scout-table-toolbar>div{display:flex;align-items:center;gap:9px} +.scout-table-toolbar strong{font-weight:760;color:#4e6683} +.scout-table-toolbar button:not([data-slot=button]){ + display:inline-flex; + align-items:center; + gap:5px; + border:0; + background:transparent; + color:#3b5c80; + font-size:9px; + font-weight:760; +} +.scout-table-toolbar svg{width:13px} +.scout-table-toolbar select{ + height:31px; + border:1px solid var(--scout-border); + border-radius:7px; + background:#fff; + color:#173456; + font-size:9px; + padding:0 8px; +} +.scout-table-wrap{position:relative;overflow:auto;max-width:100%} +.scout-database-table{ + width:100%; + min-width:2100px; + border-collapse:separate; + border-spacing:0; + background:#fff; +} +.scout-database-table th, +.scout-database-table td{ + padding:0 8px; + border-bottom:1px solid #e8eff3; + vertical-align:middle; + text-align:left; + white-space:nowrap; +} +.scout-database-table th{ + height:31px; + color:#596b84; + background:#fbfdfe; + font-size:7px; + font-weight:850; + text-transform:uppercase; + letter-spacing:.025em; +} +.scout-database-table th button{ + display:inline-flex; + align-items:center; + gap:4px; + padding:0; + border:0; + background:transparent; + color:inherit; + font:inherit; + text-transform:inherit; +} +.scout-database-table .sub-columns th{ + height:24px; + color:#708198; + background:#fff; + font-size:7px; + text-align:center; +} +.scout-database-table tbody tr{ + height:63px; + cursor:pointer; + transition:background .14s ease; +} +.scout-database-table tbody tr:hover{background:#f8fcfb} +.scout-database-table td{ + color:#0d2246; + font-size:8px; + font-weight:700; +} +.database-player-cell{ + display:flex; + align-items:center; + gap:10px; + min-width:210px; +} +.database-player-cell .player-face{ + width:42px!important; + height:46px!important; + border-radius:8px!important; +} +.database-player-cell>span, +.club-cell, +.role-cell, +.knowledge-cell, +.money-cell, +.contract-cell, +.recommendation-cell{ + display:flex; + flex-direction:column; + gap:4px; +} +.database-player-cell strong, +.role-cell strong, +.knowledge-cell strong, +.money-cell strong, +.contract-cell strong{font-size:9px;color:#10244b} +.database-player-cell small, +.role-cell small, +.knowledge-cell small, +.money-cell small, +.contract-cell small{color:#65768d;font-size:7px;font-weight:650} +.nation-chip{ + display:flex; + align-items:center; + gap:5px; + min-width:42px; +} +.nation-chip small{color:#304864;font-size:7px;font-weight:800} +.club-cell{ + flex-direction:row; + align-items:center; + gap:7px; + min-width:86px; +} +.club-cell .club-logo{ + width:19px!important; + height:19px!important; + border-radius:50%!important; +} +.club-cell span{max-width:76px;overflow:hidden;text-overflow:ellipsis} +.position-pills{ + display:flex; + gap:5px; +} +.position-pills span{ + min-width:28px; + padding:5px 7px; + border-radius:5px; + background:#f1f5f8; + color:#253b5a; + text-align:center; + font-size:7px; + font-weight:800; +} +.scout-score-ring{ + --ring-color:#aebbc8; + position:relative; + display:grid; + place-items:center; + width:35px; + height:35px; + border-radius:50%; + background:conic-gradient(var(--ring-color) var(--score),#e7edf2 0); +} +.scout-score-ring:after{ + content:""; + position:absolute; + inset:4px; + border-radius:50%; + background:#fff; +} +.scout-score-ring strong{ + position:relative; + z-index:1; + color:#10244b; + font-size:8px; + font-weight:850; +} +.scout-score-strong{--ring-color:var(--scout-green)} +.scout-score-medium{--ring-color:var(--scout-orange)} +.scout-score-poor{--ring-color:#db716a} +.scout-score-unknown{--ring-color:#b8c4ce} +.form-chip-row{ + display:flex; + align-items:center; + gap:3px; +} +.form-chip-row i{ + min-width:24px; + padding:4px 3px; + border-radius:4px; + font-style:normal; + text-align:center; + font-size:7px; +} +.form-chip-row i[data-tone=good]{background:#cdf4da;color:#127242} +.form-chip-row i[data-tone=ok]{background:#fff1c9;color:#9a6500} +.form-chip-row i[data-tone=risk]{background:#ffdfd7;color:#a33e2f} +.form-chip-row i[data-tone=unknown]{background:#eef3f6;color:#8b98a8} +.interest-pill{ + display:inline-flex; + align-items:center; + justify-content:center; + min-width:62px; + padding:5px 8px; + border-radius:999px; + background:#edf3f7; + color:#416080; + font-size:7px; + font-weight:850; +} +.interest-pill.interested, +.interest-pill.very-interested, +.interest-pill.transfer-listed{background:#dcf8eb;color:#007e61} +.interest-pill.loan-listed, +.interest-pill.open{background:#e8f1ff;color:#2d67bb} +.interest-pill.not-for-sale{background:#fff0dc;color:#a06000} +.recommendation-cell{ + flex-direction:row; + align-items:center; + gap:7px; +} +.recommendation-cell span:last-child{ + max-width:72px; + color:#334b69; + font-size:7px; + line-height:1.2; + white-space:normal; +} +.scout-room-empty{ + position:absolute; + inset:82px 0 0; + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + min-height:260px; + background:#fff; + color:#65768d; + text-align:center; +} +.scout-room-empty svg{width:30px;color:var(--scout-green)} +.scout-room-empty h2{margin:12px 0 5px;color:var(--scout-navy);font-size:15px} +.scout-room-empty p{margin:0;max-width:420px;font-size:10px} +.scout-pagination{ + display:grid; + grid-template-columns:1fr auto 1fr; + align-items:center; + gap:12px; + min-height:58px; + padding:0 16px; + color:#5f7189; + font-size:9px; +} +.scout-pagination label{display:flex;align-items:center;gap:8px} +.scout-pagination select{ + height:30px; + border:1px solid var(--scout-border); + border-radius:7px; + background:#fff; + padding:0 8px; +} +.scout-pagination div{display:flex;align-items:center;gap:7px} +.scout-pagination button{ + display:grid; + place-items:center; + min-width:29px; + height:29px; + border:0; + border-radius:7px; + background:transparent; + color:#345373; + font-size:9px; + font-weight:800; +} +.scout-pagination button.active{background:#e3f7f1;color:#00876e} +.scout-pagination button:disabled{opacity:.4} +.scout-pagination svg{width:13px} +.scout-pagination>span{justify-self:end;font-weight:760} +@media(max-width:1500px){ + .scout-filter-strip{grid-template-columns:minmax(260px,1fr) repeat(4,minmax(120px,1fr))} + .joining-toggle,.more-filter-button{min-height:44px} + .scout-metric-strip{grid-template-columns:repeat(4,1fr)} +} +@media(max-width:1100px){ + .scout-database-layout{grid-template-columns:1fr} + .scout-left-filters{display:none} + .scout-room-hero{flex-direction:column} +} + +/* 0.1.12 primary navigation moved from sidebar to topbar */ +.app-canvas, +.app-canvas:has(.scout-room-reference){ + display:grid; + grid-template-columns:1fr; + grid-template-rows:68px minmax(0,1fr); + min-width:1180px; + width:100%; + max-width:100vw; + overflow-x:hidden; +} +.app-sidebar, +.app-canvas:has(.scout-room-reference) .app-sidebar{ + position:sticky; + inset:auto; + top:0; + z-index:30; + grid-row:1; + grid-column:1; + width:auto; + height:68px; + flex-direction:row; + align-items:center; + gap:16px; + padding:0 28px; + border-right:0; + border-bottom:1px solid #d8e3eb; + background:rgba(251,253,253,.96); + backdrop-filter:blur(16px); +} +.app-main{ + grid-row:2; + grid-column:1; + min-width:0; + width:100%; + max-width:100vw; + margin-left:0; + overflow-x:hidden; +} +.brand, +.app-canvas:has(.scout-room-reference) .brand{ + width:auto; + flex:0 0 auto; + justify-content:flex-start; + gap:10px; + padding:0; +} +.brand-mark, +.app-canvas:has(.scout-room-reference) .brand-mark{ + width:34px; + height:34px; +} +.brand-mark:before, +.app-canvas:has(.scout-room-reference) .brand-mark:before{width:43px} +.brand-mark:after, +.app-canvas:has(.scout-room-reference) .brand-mark:after{height:43px} +.brand-copy, +.app-canvas:has(.scout-room-reference) .brand-copy{ + display:flex; +} +.brand-copy strong{font-size:15px} +.brand-copy small{font-size:11px} +.nav-list, +.app-canvas:has(.scout-room-reference) .nav-list{ + display:flex; + flex-direction:row; + align-items:center; + gap:6px; + margin-left:12px; +} +.nav-item, +.app-canvas:has(.scout-room-reference) .nav-item{ + width:auto; + height:38px; + justify-content:center; + gap:8px; + padding:0 13px; + border-radius:10px; + font-size:12px; +} +.app-canvas:has(.scout-room-reference) .nav-item span{ + display:inline; +} +.nav-item svg, +.app-canvas:has(.scout-room-reference) .nav-item svg{ + width:17px; + height:17px; +} +.sidebar-spacer{flex:1} +.sidebar-footer, +.app-canvas:has(.scout-room-reference) .sidebar-footer{ + display:flex; + align-items:center; + width:auto; + gap:8px; + padding:0; + margin:0; + border-top:0; +} +.app-canvas:has(.scout-room-reference) .sidebar-footer span, +.app-canvas:has(.scout-room-reference) .sidebar-footer svg{ + display:flex; +} +.sidebar-footer>span:nth-child(2){display:flex} +.sidebar-collapse{display:none} +.topbar{ + top:68px; +} +html,body{ + max-width:100vw; + overflow-x:hidden; +} +.scout-filter-strip{display:none} +.scout-metric-strip{margin-top:10px} +.sidebar-search-filter{ + position:relative; + margin:8px 0 12px; +} +.sidebar-search-filter svg{ + position:absolute; + left:11px; + top:50%; + z-index:1; + width:15px; + transform:translateY(-50%); + color:#536b89; +} +.sidebar-search-filter input{ + height:38px; + padding-left:34px; + border:1px solid var(--scout-border); + border-radius:8px; + background:#fff; + color:var(--scout-navy); + font-size:10px; +} +.sidebar-joining-toggle{ + justify-content:flex-start; + min-height:38px; + margin-bottom:10px; + border-color:var(--scout-border); +} +.side-select-inline{ + margin:0 0 8px!important; +} diff --git a/src/components/favorited-players-screen.tsx b/src/components/favorited-players-screen.tsx index cf5618e..c875b4d 100644 --- a/src/components/favorited-players-screen.tsx +++ b/src/components/favorited-players-screen.tsx @@ -106,7 +106,7 @@ export function FavoritedPlayersScreen({ ))} ) : ( -

No shortlisted players

Add players from Scout Room. Their identity resolves against the active save and hidden evidence remains masked.

+

No shortlisted players

Add players from Scout Room. Their profile opens from the active save when enough data is mapped.

)} {compareIds.length === 2 ? ( diff --git a/src/components/glass-scout-app.tsx b/src/components/glass-scout-app.tsx index eaca432..946fdb2 100644 --- a/src/components/glass-scout-app.tsx +++ b/src/components/glass-scout-app.tsx @@ -115,6 +115,7 @@ export function GlassScoutApp() { const [snapshot, setSnapshot] = useState(initialSnapshot); const [selectedPlayerId, setSelectedPlayerId] = useState(null); const [selectedClubId, setSelectedClubId] = useState(null); + const [returnScreen, setReturnScreen] = useState("Scout Room"); const [globalIndexed, setGlobalIndexed] = useState([]); const [favorites, setFavorites] = useState(() => { if (typeof window === "undefined") return []; @@ -173,11 +174,13 @@ export function GlassScoutApp() { } }, []); const openPlayer = useCallback((playerId: string) => { + setReturnScreen((current) => screen === "Player Profile" || screen === "Club Profile" ? current : screen); setSelectedPlayerId(playerId); setScreen("Player Profile"); void loadIndexedPlayerProfile(playerId); - }, [loadIndexedPlayerProfile]); + }, [loadIndexedPlayerProfile, screen]); const openClub = (clubId: string) => { + setReturnScreen((current) => screen === "Player Profile" || screen === "Club Profile" ? current : screen); setSelectedClubId(clubId); setScreen("Club Profile"); }; @@ -194,7 +197,7 @@ export function GlassScoutApp() { const content = screen === "Dashboard" ? : screen === "Squad" ? : - screen === "Tactical Board" ? : + screen === "Tactical Board" ? : screen === "Scout Room" ? : screen === "Shortlist" ? : screen === "Player Profile" ? ( @@ -203,10 +206,10 @@ export function GlassScoutApp() { snapshot={snapshot} favorite={selectedPlayerId ? favorites.some((record) => record.playerId === selectedPlayerId) : false} onToggleFavorite={() => selectedPlayerId && togglePlayerFavorite(selectedPlayerId)} - onBack={() => setScreen("Scout Room")} + onBack={() => setScreen(returnScreen)} onOpenClub={openClub} /> - ) : screen === "Club Profile" ? setScreen("Scout Room")} onOpenPlayer={openPlayer} /> : + ) : screen === "Club Profile" ? setScreen(returnScreen)} onOpenPlayer={openPlayer} /> : ; return ( diff --git a/src/components/recruitment-screen.tsx b/src/components/recruitment-screen.tsx index 8feabb1..126c0f7 100644 --- a/src/components/recruitment-screen.tsx +++ b/src/components/recruitment-screen.tsx @@ -1,60 +1,671 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { ArrowDownUp, Database, Search, ShieldCheck, Star } from "lucide-react"; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { + ArrowDownUp, + BookmarkPlus, + ChevronDown, + ChevronLeft, + ChevronRight, + Columns3, + Database, + Download, + LayoutGrid, + List, + MoreHorizontal, + Search, + ShieldCheck, + SlidersHorizontal, + Sparkles, + Star, + Target, + TrendingUp, + Users, +} from "lucide-react"; import type { IndexedPlayerSearchResult, LiveFootballSnapshot, LivePlayer } from "@/domain/adapters"; import { searchIndexedPlayers } from "@/domain/adapters"; import type { FavoriteRecord } from "@/domain/live-data"; +import { groupPlayerPosition, positionGroups } from "@/domain/live-data"; import { LiveDataState } from "@/components/live-data-state"; import { PlayerFace } from "@/components/player-face"; import { ClubLogo } from "@/components/club-logo"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -type SortKey = "name" | "role" | "knowledge" | "interest" | "recommendation"; +type SortKey = + | "relevance" + | "name" + | "age" + | "nation" + | "club" + | "roleFit" + | "knowledge" + | "form" + | "xg90" + | "value" + | "wage" + | "recommendation"; + type SortDirection = "asc" | "desc"; +type SavedFilter = "none" | "shortlist" | "u23" | "value" | "expiring"; + +type RecommendationResult = { + score: number | null; + label: string; + tone: "elite" | "strong" | "medium" | "low" | "unknown"; + details: string[]; +}; + +type PlayerRow = { + player: LivePlayer; + clubName: string | null; + clubId: string | null; + knowledgeLabel: string; + roleFitScore: number | null; + tacticPositionMatch: boolean | null; + recommendation: RecommendationResult; + interest: string; + valueAmount: number | null; + wageAmount: number | null; + formScore: number | null; + xg90: number | null; + xa90: number | null; + dribbles90: number | null; + progressivePasses90: number | null; + tackles90: number | null; + aerialWinPct: number | null; + passPct: number | null; +}; + +const PAGE_SIZE = 25; +const SCOUT_ROOM_STATE_KEY = "glassscout-scout-room-view-v2"; + +type ScoutRoomViewState = { + query: string; + interestedOnly: boolean; + position: string; + role: string; + nationality: string; + club: string; + age: string; + marketValue: string; + contract: string; + roleFit: string; + knowledge: string; + savedFilter: SavedFilter; + positionGroup: string; + interestFilter: string; + minRoleFit: number; + maxAge: number; + maxMarketValue: number; + filtersOpen: boolean; + sortKey: SortKey; + sortDirection: SortDirection; + page: number; +}; + +const defaultScoutRoomState: ScoutRoomViewState = { + query: "", + interestedOnly: false, + position: "All", + role: "All", + nationality: "All", + club: "All", + age: "16 - 40", + marketValue: "Any", + contract: "Any", + roleFit: "Any", + knowledge: "All", + savedFilter: "none", + positionGroup: "All", + interestFilter: "Any", + minRoleFit: 0, + maxAge: 40, + maxMarketValue: 200_000_000, + filtersOpen: true, + sortKey: "relevance", + sortDirection: "desc", + page: 1, +}; + +function readScoutRoomState(): ScoutRoomViewState { + if (typeof window === "undefined") return defaultScoutRoomState; + const stored = window.sessionStorage.getItem(SCOUT_ROOM_STATE_KEY); + if (!stored) return defaultScoutRoomState; + try { + const parsed = JSON.parse(stored) as Partial; + return { ...defaultScoutRoomState, ...parsed }; + } catch { + window.sessionStorage.removeItem(SCOUT_ROOM_STATE_KEY); + return defaultScoutRoomState; + } +} + +const interestedLabels = new Set([ + "Very interested", + "Interested", + "Slightly interested", + "Only loan possible", + "Transfer listed", + "Loan listed", + "Open", +]); -const interestedLabels = new Set(["Very interested", "Interested", "Slightly interested", "Only loan possible", "Transfer listed", "Loan listed"]); +const nationFlags: Record = { + Argentina: "🇦🇷", + Belgium: "🇧🇪", + Brazil: "🇧🇷", + Croatia: "🇭🇷", + Denmark: "🇩🇰", + England: "🏴", + France: "🇫🇷", + Germany: "🇩🇪", + Ghana: "🇬🇭", + Italy: "🇮🇹", + Netherlands: "🇳🇱", + Norway: "🇳🇴", + Portugal: "🇵🇹", + Scotland: "🏴", + Spain: "🇪🇸", + Sweden: "🇸🇪", + Turkey: "🇹🇷", + Wales: "🏴", +}; function indexedPlayer(result: IndexedPlayerSearchResult): LivePlayer { return { - id: result.id, name: result.name, age: result.age ?? null, nationality: result.nationality ?? null, positions: result.positions, - bestRole: result.bestRole ?? null, currentAbility: null, potentialAbility: null, form: result.averageRating == null ? null : result.averageRating.toFixed(2), averageRating: result.averageRating ?? null, - minutesPlayed: result.minutesPlayed ?? null, goals: result.goals ?? null, assists: result.assists ?? null, contractStatus: result.contractStatus ?? null, value: result.value ?? null, wage: result.wage ?? null, - squadImportance: null, developmentTrend: null, tacticalFit: null, roleFit: result.roleFit ?? null, strengths: [], - weaknesses: [], clubId: result.clubId ?? null, clubName: result.clubName ?? null, transferInterest: null, loanInterest: null, transferAvailable: null, - loanAvailable: null, attributes: {}, scoutKnowledge: result.scoutKnowledge, - scoutConfidence: result.scoutConfidence, preferredFoot: null, + id: result.id, + name: result.name, + age: result.age ?? null, + nationality: result.nationality ?? null, + positions: result.positions, + bestRole: result.bestRole ?? null, + currentAbility: null, + potentialAbility: null, + abilityScore: result.roleFit ?? null, + form: result.averageRating == null ? null : result.averageRating.toFixed(2), + averageRating: result.averageRating ?? null, + minutesPlayed: result.minutesPlayed ?? null, + goals: result.goals ?? null, + assists: result.assists ?? null, + contractStatus: result.contractStatus ?? null, + value: result.value ?? null, + wage: result.wage ?? null, + squadImportance: null, + developmentTrend: null, + tacticalFit: null, + roleFit: result.roleFit ?? null, + preferredFoot: null, + strengths: [], + weaknesses: [], + clubId: result.clubId ?? null, + clubName: result.clubName ?? null, + transferInterest: result.transferInterest ?? null, + loanInterest: result.loanInterest ?? null, + transferAvailable: result.transferAvailable ?? null, + loanAvailable: result.loanAvailable ?? null, + attributes: {}, + per90: result.per90 ?? {}, + rawStats: result.rawStats ?? {}, + inPossessionFit: result.inPossessionFit ?? null, + outOfPossessionFit: result.outOfPossessionFit ?? null, + projectedInPossessionFit: result.projectedInPossessionFit ?? null, + projectedOutOfPossessionFit: result.projectedOutOfPossessionFit ?? null, + efficiencyScore: result.efficiencyScore ?? null, + scoutKnowledge: result.scoutKnowledge, + scoutConfidence: result.scoutConfidence, + marketValueAmount: result.marketValueAmount ?? null, + contractRemaining: result.contractRemaining ?? null, }; } -function interestLabel(player: LivePlayer) { - return player.transferInterest ?? player.loanInterest ?? "Unknown"; +function clamp(value: number, min = 0, max = 100) { + return Math.max(min, Math.min(max, value)); +} + +function formatCount(value: number | null | undefined) { + return new Intl.NumberFormat("en-US").format(value ?? 0); +} + +function formatCompactCount(value: number | null | undefined) { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value ?? 0); +} + +function displayValue(value: string | number | null | undefined, fallback = "Unknown") { + if (value === null || value === undefined || value === "") return fallback; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : fallback; + return value; +} + +function numericValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function per90(player: LivePlayer, key: string) { + return numericValue(player.per90?.[key]); +} + +function rawStat(player: LivePlayer, key: string) { + return numericValue(player.rawStats?.[key]); +} + +function ratioPercent(part: number | null, total: number | null) { + if (part == null || total == null || total <= 0) return null; + return Math.round((part / total) * 100); +} + +function parseMoney(value: string | number | null | undefined): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (!value) return null; + const text = value + .replace(/\u00e2\u201a\u00ac/g, "€") + .replace(/p\/w/gi, "") + .replace(/[€£$,\s]/g, "") + .trim(); + const match = text.match(/(-?\d+(?:\.\d+)?)([kmb])?/i); + if (!match) return null; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return null; + const suffix = match[2]?.toLowerCase(); + if (suffix === "b") return amount * 1_000_000_000; + if (suffix === "m") return amount * 1_000_000; + if (suffix === "k") return amount * 1_000; + return amount; } function knowledgeLabel(player: LivePlayer) { - return (player.scoutKnowledge ?? "unknown").replaceAll("_", " "); + switch (player.scoutKnowledge) { + case "fully_known": + return "Fully known"; + case "partly_known": + return "Partly known"; + case "needs_scouting": + return "Needs scouting"; + case "missing_data": + return "Missing data"; + case "unknown": + default: + return "Unknown"; + } +} + +function interestLabel(player: LivePlayer) { + if (player.transferInterest) return player.transferInterest; + if (player.loanInterest) return player.loanInterest; + if (player.transferAvailable) return "Transfer listed"; + if (player.loanAvailable) return "Loan listed"; + return "Unknown"; } -function recommendation(player: LivePlayer) { - const score = player.tacticalFit ?? player.roleFit; - if (score == null) return { label: "Not enough evidence", value: "—", sort: -1 }; +function interestScore(label: string) { + const normalized = label.toLowerCase(); + if (normalized.includes("very interested")) return 100; + if (normalized === "interested" || normalized.includes("transfer listed")) return 82; + if (normalized.includes("slightly") || normalized.includes("loan listed")) return 66; + if (normalized.includes("open")) return 60; + if (normalized.includes("not for sale") || normalized.includes("not interested")) return 15; + return 44; +} + +function ratingScore(rating: number | null) { + if (rating == null) return null; + return clamp(((rating - 6.25) / 1.35) * 100); +} + +function norm(value: number | null, target: number) { + if (value == null || target <= 0) return null; + return clamp((value / target) * 100); +} + +function average(values: Array) { + const clean = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + if (!clean.length) return null; + return clean.reduce((sum, value) => sum + value, 0) / clean.length; +} + +function performanceScore(player: LivePlayer) { + const form = ratingScore(player.averageRating); + const attacking = average([ + norm(per90(player, "xgPer90"), 0.65), + norm(per90(player, "xaPer90"), 0.45), + norm(per90(player, "goalsPer90"), 0.6), + norm(per90(player, "assistsPer90"), 0.45), + norm(per90(player, "dribblesPer90"), 4.5), + norm(per90(player, "keyPassesPer90"), 3.0), + norm(per90(player, "progressivePassesPer90"), 7.0), + ]); + const defensive = average([ + norm(per90(player, "tacklesWonPer90"), 4.5), + norm(per90(player, "interceptionsPer90"), 2.8), + norm(per90(player, "headersWonPer90"), 5.5), + ]); + const evidence = average([attacking, defensive]); + if (form == null && evidence == null) return null; + return Math.round(average([form == null ? null : form * 0.55, evidence == null ? null : evidence * 0.45]) ?? form ?? evidence ?? 0); +} + +function tacticPositionSet(snapshot: LiveFootballSnapshot) { + const set = new Set(); + for (const slot of snapshot.tactic?.slots ?? []) { + for (const token of positionTokens(slot.position)) set.add(token); + } + return set; +} + +function positionTokens(value: string | null | undefined) { + if (!value) return []; + return value + .toUpperCase() + .replace(/[(),]/g, " ") + .split(/[\s/|-]+/) + .map((token) => { + if (token === "CB") return "DC"; + if (token === "CM") return "MC"; + if (token === "LB") return "DL"; + if (token === "RB") return "DR"; + if (token === "LW") return "AML"; + if (token === "RW") return "AMR"; + if (token === "CF") return "ST"; + return token; + }) + .filter((token) => token.length >= 2 && token !== "ROLE" && token !== "DUTY"); +} + +function matchesTacticPosition(player: LivePlayer, tacticPositions: Set) { + if (!tacticPositions.size) return null; + const playerTokens = player.positions.flatMap(positionTokens); + return playerTokens.some((token) => tacticPositions.has(token)); +} + +function roleFitScore(player: LivePlayer, tacticPositions: Set) { + const phaseFit = average([ + player.inPossessionFit ?? null, + player.outOfPossessionFit ?? null, + player.projectedInPossessionFit ?? null, + player.projectedOutOfPossessionFit ?? null, + ]); + let score = player.tacticalFit ?? player.roleFit ?? phaseFit ?? player.efficiencyScore ?? null; + if (score == null && player.playableRoles?.length) { + score = player.playableRoles[0]?.score ?? null; + } + const tacticMatch = matchesTacticPosition(player, tacticPositions); + if (score != null && tacticMatch === false) score = clamp(score - 14); + return score == null ? null : Math.round(clamp(score)); +} + +function valueRealismScore(player: LivePlayer, roleFit: number | null, performance: number | null) { + const valueAmount = player.marketValueAmount ?? parseMoney(player.value); + const wageAmount = parseMoney(player.wage); + let score = valueAmount == null ? 46 : 70; + const footballQuality = average([roleFit, performance]) ?? 45; + + if (valueAmount != null) { + if (valueAmount <= 2_000_000 && footballQuality >= 55) score += 18; + else if (valueAmount <= 15_000_000 && footballQuality >= 62) score += 12; + else if (valueAmount >= 150_000_000) score -= 35; + else if (valueAmount >= 80_000_000) score -= 22; + else if (valueAmount >= 45_000_000 && footballQuality < 70) score -= 15; + } + + if (player.valuationLabel === "undervalued") score += 14; + if (player.valuationLabel === "overpriced") score -= 24; + if (player.valuationLabel === "unavailable") score -= 4; + + if (wageAmount != null) { + if (wageAmount >= 180_000) score -= 28; + else if (wageAmount >= 100_000) score -= 16; + else if (wageAmount <= 35_000 && footballQuality >= 55) score += 8; + } + + return Math.round(clamp(score)); +} + +function recommendationFor(player: LivePlayer, roleFit: number | null, tacticMatch: boolean | null): RecommendationResult { + const form = ratingScore(player.averageRating); + const performance = performanceScore(player); + const interest = interestLabel(player); + const market = valueRealismScore(player, roleFit, performance); const confidence = player.scoutConfidence ?? 0; - if (confidence >= 95) return { label: score >= 75 ? "Priority review" : score >= 55 ? "Scout further" : "Low priority", value: `${score}`, sort: score }; - const margin = Math.max(4, Math.round((100 - confidence) / 4)); - return { label: "Estimated interval", value: `${Math.max(0, score - margin)}–${Math.min(100, score + margin)}`, sort: score }; + const availableEvidence = [roleFit, performance, form, market].filter((value) => value != null).length; + + if (availableEvidence < 2 && confidence < 35) { + return { + score: null, + label: "Scout first", + tone: "unknown", + details: ["Not enough revealed evidence"], + }; + } + + let base = + (roleFit ?? 45) * 0.34 + + (performance ?? 45) * 0.2 + + (form ?? 45) * 0.12 + + market * 0.18 + + interestScore(interest) * 0.1 + + clamp(confidence) * 0.06; + + if (tacticMatch === false) base -= 8; + if (interest.toLowerCase().includes("not for sale")) base -= 12; + + const score = Math.round(clamp(base)); + const tone = score >= 78 ? "elite" : score >= 67 ? "strong" : score >= 54 ? "medium" : score >= 40 ? "low" : "unknown"; + const label = + score >= 78 + ? "Priority target" + : score >= 67 + ? "Strong option" + : score >= 54 + ? "Scout deeper" + : score >= 40 + ? "Watchlist only" + : "Poor value"; + + return { + score, + label, + tone, + details: [ + roleFit == null ? "role fit unknown" : `role fit ${roleFit}`, + performance == null ? "per-90 limited" : `performance ${performance}`, + `market realism ${market}`, + `${confidence}% knowledge`, + ], + }; +} + +function makeRow(player: LivePlayer, snapshot: LiveFootballSnapshot, clubName: string | null, clubId: string | null): PlayerRow { + const tacticPositions = tacticPositionSet(snapshot); + const roleFit = roleFitScore(player, tacticPositions); + const tacticMatch = matchesTacticPosition(player, tacticPositions); + const form = ratingScore(player.averageRating); + return { + player, + clubName, + clubId, + knowledgeLabel: knowledgeLabel(player), + roleFitScore: roleFit, + tacticPositionMatch: tacticMatch, + recommendation: recommendationFor(player, roleFit, tacticMatch), + interest: interestLabel(player), + valueAmount: player.marketValueAmount ?? parseMoney(player.value), + wageAmount: parseMoney(player.wage), + formScore: form == null ? null : Math.round(form), + xg90: per90(player, "xgPer90"), + xa90: per90(player, "xaPer90"), + dribbles90: per90(player, "dribblesPer90"), + progressivePasses90: per90(player, "progressivePassesPer90"), + tackles90: per90(player, "tacklesWonPer90"), + aerialWinPct: ratioPercent(rawStat(player, "headersWon"), rawStat(player, "headersAttempted")), + passPct: ratioPercent(rawStat(player, "passesCompleted"), rawStat(player, "passesAttempted")), + }; +} + +function ScoreRing({ value, label = "score" }: { value: number | null; label?: string }) { + const safe = value == null ? 0 : clamp(value); + const tone = value == null ? "unknown" : safe >= 72 ? "strong" : safe >= 55 ? "medium" : "poor"; + return ( + + {value ?? "—"} + + ); +} + +function FormChips({ rating }: { rating: number | null }) { + const base = rating ?? 6.6; + const values = Array.from({ length: 5 }, (_, index) => { + if (rating == null) return null; + const offset = [-0.08, 0.04, -0.02, 0.09, 0.0][index] ?? 0; + return Math.round((base + offset) * 10) / 10; + }); + return ( + + {values.map((value, index) => ( + = 7.1 ? "good" : value >= 6.8 ? "ok" : "risk"}> + {value == null ? "–" : value.toFixed(1)} + + ))} + + ); +} + +function PlayerNation({ nation }: { nation: string | null }) { + const flag = nation ? nationFlags[nation] : null; + return {flag ?? "—"}{nation?.slice(0, 3).toUpperCase() ?? "UNK"}; } -function RatingRing({ value }: { value: number | null }) { - const safe = value == null ? 0 : Math.max(0, Math.min(100, value)); - const tone = value == null ? "unknown" : value >= 70 ? "strong" : value >= 50 ? "medium" : "poor"; - return {value ?? "—"}; +function MetricCard({ title, value, note, active, icon, variant = "line", onClick }: { + title: string; + value: string; + note: string; + active?: boolean; + icon: ReactNode; + variant?: "line" | "donut" | "bars"; + onClick?: () => void; +}) { + return ( + + ); } -function SortButton({ label, value, current, direction, onSort }: { - label: string; value: SortKey; current: SortKey; direction: SortDirection; onSort: (value: SortKey) => void; +function SavedFilterButton({ value, active, label, detail, icon, onClick }: { + value: SavedFilter; + active: SavedFilter; + label: string; + detail: string; + icon: React.ReactNode; + onClick: (value: SavedFilter) => void; }) { - return ; + return ( + + ); +} + +function sortValue(row: PlayerRow, key: SortKey): string | number { + switch (key) { + case "name": + return row.player.name; + case "age": + return row.player.age ?? 999; + case "nation": + return row.player.nationality ?? ""; + case "club": + return row.clubName ?? ""; + case "roleFit": + return row.roleFitScore ?? -1; + case "knowledge": + return row.player.scoutConfidence ?? -1; + case "form": + return row.formScore ?? -1; + case "xg90": + return row.xg90 ?? -1; + case "value": + return row.valueAmount ?? Number.MAX_SAFE_INTEGER; + case "wage": + return row.wageAmount ?? Number.MAX_SAFE_INTEGER; + case "recommendation": + case "relevance": + return row.recommendation.score ?? -1; + } +} + +function sortRows(rows: PlayerRow[], key: SortKey, direction: SortDirection) { + const multiplier = direction === "asc" ? 1 : -1; + return rows.toSorted((left, right) => { + const leftValue = sortValue(left, key); + const rightValue = sortValue(right, key); + const comparison = typeof leftValue === "number" && typeof rightValue === "number" + ? leftValue - rightValue + : String(leftValue).localeCompare(String(rightValue)); + return comparison * multiplier || left.player.name.localeCompare(right.player.name); + }); +} + +function contractIsExpiring(player: LivePlayer, horizonMonths: number) { + const text = `${player.contractStatus ?? ""} ${player.contractRemaining ?? ""}`.toLowerCase(); + if (!text.trim()) return false; + if (text.includes("month") || text.includes("expires")) { + const number = Number(text.match(/(\d+)/)?.[1] ?? NaN); + if (Number.isFinite(number) && text.includes("month")) return number <= horizonMonths; + } + return text.includes("2025") || text.includes("2026") || text.includes("expiring"); +} + +function roleFitBand(value: string, score: number | null) { + if (value === "Any") return true; + const min = Number(value.replace("+", "")); + return score != null && score >= min; +} + +function ageBand(value: string, age: number | null) { + if (value === "16 - 40") return age == null || (age >= 16 && age <= 40); + if (age == null) return false; + if (value === "U21") return age <= 21; + if (value === "U23") return age <= 23; + if (value === "24 - 27") return age >= 24 && age <= 27; + if (value === "28 - 32") return age >= 28 && age <= 32; + if (value === "33+") return age >= 33; + return true; +} + +function valueBand(value: string, amount: number | null) { + if (value === "Any") return true; + if (amount == null) return false; + if (value === "Bargain < €5M") return amount < 5_000_000; + if (value === "Under €15M") return amount < 15_000_000; + if (value === "Under €50M") return amount < 50_000_000; + if (value === "Premium €50M+") return amount >= 50_000_000; + return true; +} + +function contractBand(value: string, player: LivePlayer) { + if (value === "Any") return true; + if (value === "Expiring 12m") return contractIsExpiring(player, 12); + if (value === "Expiring 24m") return contractIsExpiring(player, 24); + if (value === "Free / no contract") return !player.contractStatus || player.contractStatus.toLowerCase().includes("free"); + return true; +} + +function rowMatchesSavedFilter(row: PlayerRow, savedFilter: SavedFilter, favoriteIds: Set) { + if (savedFilter === "none") return true; + if (savedFilter === "shortlist") return favoriteIds.has(row.player.id); + if (savedFilter === "u23") return (row.player.age ?? 99) <= 23 && (row.recommendation.score ?? 0) >= 55; + if (savedFilter === "value") return (row.recommendation.score ?? 0) >= 58 && (row.valueAmount == null || row.valueAmount < 15_000_000); + if (savedFilter === "expiring") return contractIsExpiring(row.player, 18); + return true; } export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onToggleFavorite, onOpenPlayer }: { @@ -65,60 +676,167 @@ export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onTo onToggleFavorite: (playerId: string) => void; onOpenPlayer: (playerId: string) => void; }) { - const [query, setQuery] = useState(""); - const [interestedOnly, setInterestedOnly] = useState(false); - const [position, setPosition] = useState("All"); - const [knowledge, setKnowledge] = useState("All"); + const [initialView] = useState(readScoutRoomState); + const [query, setQuery] = useState(initialView.query); + const [interestedOnly, setInterestedOnly] = useState(initialView.interestedOnly); + const [position, setPosition] = useState(initialView.position); + const [role, setRole] = useState(initialView.role); + const [nationality, setNationality] = useState(initialView.nationality); + const [club, setClub] = useState(initialView.club); + const [age, setAge] = useState(initialView.age); + const [marketValue, setMarketValue] = useState(initialView.marketValue); + const [contract, setContract] = useState(initialView.contract); + const [roleFit, setRoleFit] = useState(initialView.roleFit); + const [knowledge, setKnowledge] = useState(initialView.knowledge); + const [savedFilter, setSavedFilter] = useState(initialView.savedFilter); + const [positionGroup, setPositionGroup] = useState(initialView.positionGroup); + const [interestFilter, setInterestFilter] = useState(initialView.interestFilter); + const [minRoleFit, setMinRoleFit] = useState(initialView.minRoleFit); + const [maxAge, setMaxAge] = useState(initialView.maxAge); + const [maxMarketValue, setMaxMarketValue] = useState(initialView.maxMarketValue); + const [filtersOpen, setFiltersOpen] = useState(initialView.filtersOpen); const [indexed, setIndexed] = useState([]); - const [sortKey, setSortKey] = useState("recommendation"); - const [sortDirection, setSortDirection] = useState("desc"); + const [sortKey, setSortKey] = useState(initialView.sortKey); + const [sortDirection, setSortDirection] = useState(initialView.sortDirection); + const [page, setPage] = useState(initialView.page); useEffect(() => { if (snapshot.status.state !== "connected") return; const timer = window.setTimeout(() => { + if (!query.trim() && snapshot.players.length > 0) { + setIndexed([]); + return; + } searchIndexedPlayers(query).then(setIndexed).catch(() => setIndexed([])); }, query ? 180 : 0); return () => window.clearTimeout(timer); - }, [query, snapshot.status.lastSync, snapshot.status.state]); + }, [query, snapshot.players.length, snapshot.status.lastSync, snapshot.status.state]); + + useEffect(() => { + window.sessionStorage.setItem(SCOUT_ROOM_STATE_KEY, JSON.stringify({ + query, + interestedOnly, + position, + role, + nationality, + club, + age, + marketValue, + contract, + roleFit, + knowledge, + savedFilter, + positionGroup, + interestFilter, + minRoleFit, + maxAge, + maxMarketValue, + filtersOpen, + sortKey, + sortDirection, + page, + } satisfies ScoutRoomViewState)); + }, [age, club, contract, filtersOpen, interestFilter, interestedOnly, knowledge, marketValue, maxAge, maxMarketValue, minRoleFit, nationality, page, position, positionGroup, query, role, roleFit, savedFilter, sortDirection, sortKey]); - const clubById = useMemo(() => new Map(snapshot.clubs.map((club) => [club.id, club])), [snapshot.clubs]); - const liveById = useMemo(() => new Map(snapshot.players.map((player) => [player.id, player])), [snapshot.players]); + const clubById = useMemo(() => new Map(snapshot.clubs.map((item) => [item.id, item])), [snapshot.clubs]); const favoriteIds = useMemo(() => new Set(favorites.map((record) => record.playerId)), [favorites]); + const allPlayers = useMemo(() => { const merged = new Map(snapshot.players.map((player) => [player.id, player])); - for (const result of indexed) if (!merged.has(result.id)) merged.set(result.id, indexedPlayer(result)); + for (const result of indexed) { + if (!merged.has(result.id)) merged.set(result.id, indexedPlayer(result)); + } return [...merged.values()]; }, [indexed, snapshot.players]); - const positions = useMemo(() => ["All", ...new Set(allPlayers.flatMap((player) => player.positions))], [allPlayers]); - const players = useMemo(() => { + const rows = useMemo(() => allPlayers.map((player) => { + const mappedClub = player.clubId ? clubById.get(player.clubId) : null; + return makeRow(player, snapshot, mappedClub?.name ?? player.clubName ?? null, mappedClub?.id ?? player.clubId ?? null); + }), [allPlayers, clubById, snapshot]); + + const filterOptions = useMemo(() => { + const positions = ["All", ...new Set(rows.flatMap((row) => row.player.positions).filter(Boolean))].sort(); + const roles = ["All", ...new Set(rows.map((row) => row.player.bestRole).filter((item): item is string => Boolean(item)))].sort(); + const nations = ["All", ...new Set(rows.map((row) => row.player.nationality).filter((item): item is string => Boolean(item)))].sort(); + const clubs = ["All", ...new Set(rows.map((row) => row.clubName).filter((item): item is string => Boolean(item)))].sort(); + return { positions, roles, nations, clubs }; + }, [rows]); + + const filteredRows = useMemo(() => { const normalized = query.trim().toLowerCase(); - const result = allPlayers.filter((player) => { - const club = player.clubId ? clubById.get(player.clubId) : null; - const clubName = club?.name ?? player.clubName; - const matchesQuery = !normalized || [player.name, clubName, player.nationality, ...player.positions].filter(Boolean).some((item) => item?.toLowerCase().includes(normalized)); - return matchesQuery - && (!interestedOnly || interestedLabels.has(interestLabel(player))) + return rows.filter((row) => { + const player = row.player; + const searchable = [player.name, row.clubName, player.nationality, player.bestRole, ...player.positions].filter(Boolean).join(" ").toLowerCase(); + return (!normalized || searchable.includes(normalized)) + && (!interestedOnly || interestedLabels.has(row.interest)) && (position === "All" || player.positions.includes(position)) - && (knowledge === "All" || knowledgeLabel(player) === knowledge); - }); - const direction = sortDirection === "asc" ? 1 : -1; - return result.toSorted((left, right) => { - const values: Record = { - name: [left.name, right.name], - role: [left.bestRole ?? "", right.bestRole ?? ""], - knowledge: [left.scoutConfidence ?? -1, right.scoutConfidence ?? -1], - interest: [interestLabel(left), interestLabel(right)], - recommendation: [recommendation(left).sort, recommendation(right).sort], - }; - const [a, b] = values[sortKey]; - return (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))) * direction || left.name.localeCompare(right.name); + && (role === "All" || player.bestRole === role) + && (nationality === "All" || player.nationality === nationality) + && (club === "All" || row.clubName === club) + && ageBand(age, player.age) + && valueBand(marketValue, row.valueAmount) + && contractBand(contract, player) + && roleFitBand(roleFit, row.roleFitScore) + && (knowledge === "All" || row.knowledgeLabel === knowledge) + && (positionGroup === "All" || groupPlayerPosition(player) === positionGroup) + && (interestFilter === "Any" || row.interest === interestFilter) + && (row.roleFitScore == null || row.roleFitScore >= minRoleFit) + && (player.age == null || player.age <= maxAge) + && (row.valueAmount == null || row.valueAmount <= maxMarketValue) + && rowMatchesSavedFilter(row, savedFilter, favoriteIds); }); - }, [allPlayers, clubById, interestedOnly, knowledge, position, query, sortDirection, sortKey]); + }, [age, club, contract, favoriteIds, interestFilter, interestedOnly, knowledge, marketValue, maxAge, maxMarketValue, minRoleFit, nationality, position, positionGroup, query, role, roleFit, rows, savedFilter]); + + const sortedRows = useMemo(() => sortRows(filteredRows, sortKey, sortDirection), [filteredRows, sortDirection, sortKey]); + const pageCount = Math.max(1, Math.ceil(sortedRows.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const visibleRows = sortedRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const metrics = useMemo(() => { + const indexedCount = snapshot.status.databasePlayersIndexed || rows.length; + const fullyKnown = rows.filter((row) => row.knowledgeLabel === "Fully known").length; + const wonderkids = rows.filter((row) => (row.player.age ?? 99) <= 23).length; + const bargains = rows.filter((row) => (row.recommendation.score ?? 0) >= 58 && (row.valueAmount == null || row.valueAmount < 15_000_000)).length; + const avgRecommendation = Math.round(average(rows.map((row) => row.recommendation.score)) ?? 0); + const scoutedThisWeek = snapshot.status.fullyScoutedPlayers + snapshot.status.partialScoutReports; + const topTacticFit = Math.round(average(rows.map((row) => row.roleFitScore).filter((value): value is number => value != null && value >= 50)) ?? 0); + return { indexedCount, fullyKnown, wonderkids, bargains, avgRecommendation, scoutedThisWeek, topTacticFit }; + }, [rows, snapshot.status.databasePlayersIndexed, snapshot.status.fullyScoutedPlayers, snapshot.status.partialScoutReports]); + + const setPreset = (value: SavedFilter) => { + setSavedFilter((current) => current === value ? "none" : value); + }; + + const resetFilters = () => { + setQuery(""); + setInterestedOnly(false); + setPosition("All"); + setRole("All"); + setNationality("All"); + setClub("All"); + setAge("16 - 40"); + setMarketValue("Any"); + setContract("Any"); + setRoleFit("Any"); + setKnowledge("All"); + setSavedFilter("none"); + setPositionGroup("All"); + setInterestFilter("Any"); + setMinRoleFit(0); + setMaxAge(40); + setMaxMarketValue(200_000_000); + setSortKey("relevance"); + setSortDirection("desc"); + setPage(1); + }; const onSort = (value: SortKey) => { - if (sortKey === value) setSortDirection((current) => current === "asc" ? "desc" : "asc"); - else { setSortKey(value); setSortDirection(value === "name" ? "asc" : "desc"); } + if (sortKey === value) { + setSortDirection((current) => current === "asc" ? "desc" : "asc"); + } else { + setSortKey(value); + setSortDirection(value === "name" || value === "age" || value === "value" || value === "wage" ? "asc" : "desc"); + } }; if (snapshot.status.state !== "connected") { @@ -126,48 +844,186 @@ export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onTo } return ( -
-
-

Scout Room

Search the indexed save, then judge only the evidence your club actually knows.

-
{snapshot.status.databasePlayersIndexed} indexed
-
- -
-
setQuery(event.target.value)} />
- - - +
+
+
+

Scout Room

+ {formatCount(snapshot.status.databasePlayersIndexed || rows.length)} indexed players +

Search the indexed database and evaluate players using only the evidence we know.

+
+
+ + + +
+
+ +
+ } onClick={resetFilters} active={savedFilter === "none"} /> + } onClick={() => setPreset("shortlist")} active={savedFilter === "shortlist"} /> + } variant="donut" onClick={() => setKnowledge("Fully known")} active={knowledge === "Fully known"} /> + } variant="donut" onClick={() => setPreset("u23")} active={savedFilter === "u23"} /> + } onClick={() => setPreset("value")} active={savedFilter === "value"} /> + } onClick={() => { setSortKey("recommendation"); setSortDirection("desc"); }} /> + } variant="bars" onClick={() => setKnowledge("Partly known")} active={knowledge === "Partly known"} /> + } variant="donut" onClick={() => setRoleFit("70+")} active={roleFit === "70+"} />
-
-
- - - - - Form & marketShortlist -
- {players.map((player) => { - const club = player.clubId ? clubById.get(player.clubId) : null; - const favorite = favoriteIds.has(player.id); - const rec = recommendation(player); - const hasLiveDossier = liveById.has(player.id); - const clubName = club?.name ?? player.clubName; - return ( -
onOpenPlayer(player.id)}> -
{player.name}{player.age ?? "Age unknown"} · {player.nationality ?? "Nation unknown"} · {player.positions.join(" / ") || "Position unknown"} · FM ID {player.id}{club ? : null}{clubName ?? "Club not mapped"}
-
{player.bestRole ?? "Not evaluated"}{player.tacticalFit == null ? "Live tactic required" : `${player.tacticalFit}% tactic fit`}
-
{knowledgeLabel(player)}{player.scoutConfidence == null ? "Confidence unknown" : `${player.scoutConfidence}% confidence`}
-
{interestLabel(player)}{interestLabel(player) === "Unknown" ? "Not mapped for this build" : "From FM26"}
-
{player.averageRating?.toFixed(2) ?? "Form unknown"}{player.value ?? "Value unknown"} · {player.wage ?? "Wage unknown"}
-
{rec.value}{rec.label} · {player.scoutConfidence ?? 0}% evidence
-
-
- ); - })} - {!players.length ?

{interestedOnly ? "No validated interest records yet" : "No players match this search"}

{interestedOnly ? "The filter is on by default. Interest is not mapped safely for this FM26 build, so GlassScout will not invent viable targets. Turn it off to browse the indexed database." : "Try another name or clear the filters."}

: null} +
+ {filtersOpen ? ( + + ) : ( + + )} + +
+
+
0 selected
+
Sort by
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {visibleRows.map((row) => { + const player = row.player; + const favorite = favoriteIds.has(player.id); + return ( + onOpenPlayer(player.id)}> + + + + + + + + + + + + + + + + + + + + + + + + + + ); + })} + +
PositionsBest RoleKey per 90 statsContractInterestShortlistAction
+ xG/90xA/90Drb/90Prg Pas/90Tkl/90Aerial %Pass % +
event.stopPropagation()}> + + {player.name}{player.positions.join(", ") || "Position unknown"} + {displayValue(player.age, "—")}{row.clubId ? : null}{row.clubName ?? "—"}{player.positions.slice(0, 3).map((item) => {item})}{player.bestRole ?? "Not evaluated"}{row.tacticPositionMatch === false ? "Outside tactic shape" : row.tacticPositionMatch === true ? "Tactic position match" : "No tactic shape"}{row.knowledgeLabel}{player.scoutConfidence ?? 0}%{row.xg90?.toFixed(2) ?? "–"}{row.xa90?.toFixed(2) ?? "–"}{row.dribbles90?.toFixed(1) ?? "–"}{row.progressivePasses90?.toFixed(1) ?? "–"}{row.tackles90?.toFixed(1) ?? "–"}{row.aerialWinPct == null ? "–" : `${row.aerialWinPct}%`}{row.passPct == null ? "–" : `${row.passPct}%`}{displayValue(player.value, "—")}{row.valueAmount ? €{formatCompactCount(row.valueAmount)} : null}{displayValue(player.wage, "—")}{player.contractStatus ?? "—"}{player.contractRemaining ?? ""}{row.interest}{row.recommendation.label} event.stopPropagation()}> event.stopPropagation()}>
+ {!visibleRows.length ?

No players match this view

Clear filters or broaden scout-knowledge, value, role-fit and interest settings.

: null} +
+ +
+ +
+ + {[1, 2, 3, 4, 5].filter((item) => item <= pageCount).map((item) => )} + {pageCount > 5 ? : null} + {pageCount > 5 ? : null} + +
+ {sortedRows.length ? `${(currentPage - 1) * PAGE_SIZE + 1} - ${Math.min(currentPage * PAGE_SIZE, sortedRows.length)} of ${formatCount(sortedRows.length)} players` : "0 players"} +
+
-

Indexed players may appear with identity-only records. Unknown fields stay masked and are never used as exact recommendation evidence.

); } diff --git a/src/components/settings-screen.tsx b/src/components/settings-screen.tsx index d312299..8b22ed4 100644 --- a/src/components/settings-screen.tsx +++ b/src/components/settings-screen.tsx @@ -77,7 +77,7 @@ export function SettingsScreen({
Managed squad players
{status.managedSquadPlayers}
Player records indexed
{status.databasePlayersIndexed}
Background records gated
{status.backgroundPlayersIndexed}
-
Visibility-safe players
{status.visiblePlayersLoaded}
+
Readable player profiles
{status.visiblePlayersLoaded}
Fully scouted players
{status.fullyScoutedPlayers}
Partial scout reports
{status.partialScoutReports}
Live memory tactic read
{status.liveMemoryTacticRead ?? "disabled"}
diff --git a/src/components/tactical-board.tsx b/src/components/tactical-board.tsx index c671b2d..7aedb6e 100644 --- a/src/components/tactical-board.tsx +++ b/src/components/tactical-board.tsx @@ -1,32 +1,128 @@ "use client"; -import { useState, type CSSProperties } from "react"; -import { ShieldAlert } from "lucide-react"; +import { useMemo, useState, type CSSProperties } from "react"; +import { ShieldAlert, Sparkles } from "lucide-react"; import type { LiveFootballSnapshot, LivePlayer, LiveTacticSlot } from "@/domain/adapters"; type TacticalPhase = "In Possession" | "Out of Possession" | "Combined"; -const fallbackCoordinates = [[50, 83], [20, 68], [40, 68], [60, 68], [80, 68], [50, 54], [30, 42], [70, 42], [18, 25], [82, 25], [50, 14]]; - -function coordinatesForSlot(slot: LiveTacticSlot, index: number) { - const value = slot.position.toUpperCase(); - if (value.includes("GK")) return [50, 84]; - if (value.includes("DL") || value.includes("LB")) return [18, 68]; - if (value.includes("DR") || value.includes("RB")) return [82, 68]; - if (value.includes("DC") || value.includes("CB")) return index % 2 ? [39, 68] : [61, 68]; - if (value.includes("DM")) return [50, 56]; - if (value.includes("AML") || value.includes("LW")) return [18, 25]; - if (value.includes("AMR") || value.includes("RW")) return [82, 25]; - if (value.includes("AMC")) return [50, 34]; - if (value.includes("MC") || value.includes("CM")) return index % 2 ? [34, 45] : [66, 45]; - if (value.includes("ST") || value.includes("CF")) return [50, 15]; - return fallbackCoordinates[index % fallbackCoordinates.length]; +const fallbackCoordinates = [[50, 84], [20, 68], [40, 68], [60, 68], [80, 68], [50, 56], [34, 44], [66, 44], [18, 25], [82, 25], [50, 14]]; + +const positionCoordinates: Record = { + GK: [50, 84], + SW: [50, 75], + DR: [82, 68], + DCR: [61, 68], + DC: [50, 68], + DCL: [39, 68], + DL: [18, 68], + WBR: [86, 58], + WBL: [14, 58], + DMR: [62, 56], + DM: [50, 56], + DML: [38, 56], + MR: [82, 44], + MCR: [62, 44], + MC: [50, 44], + MCL: [38, 44], + ML: [18, 44], + AMR: [82, 25], + AMCR: [63, 28], + AMC: [50, 30], + AMCL: [37, 28], + AML: [18, 25], + STR: [62, 14], + STCR: [60, 14], + STC: [50, 14], + ST: [50, 14], + STCL: [40, 14], + STL: [38, 14], +}; + +function normalizedPosition(value: string) { + const upper = value.toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (upper === "CB") return "DC"; + if (upper === "CM") return "MC"; + if (upper === "LB") return "DL"; + if (upper === "RB") return "DR"; + if (upper === "LW") return "AML"; + if (upper === "RW") return "AMR"; + if (upper === "CF") return "ST"; + return upper; +} + +function positionFamily(value: string) { + const position = normalizedPosition(value); + if (position.includes("GK")) return "GK"; + if (position.startsWith("D") && !position.startsWith("DM")) return "DEF"; + if (position.startsWith("WB")) return "WB"; + if (position.startsWith("DM")) return "DM"; + if (position.startsWith("M") && !position.startsWith("MR") && !position.startsWith("ML")) return "CM"; + if (position === "MR" || position === "ML" || position.startsWith("AMR") || position.startsWith("AML")) return "WIDE"; + if (position.startsWith("AM")) return "AM"; + if (position.startsWith("ST")) return "ST"; + return position; +} + +function coordinatesForSlot(slot: LiveTacticSlot, index: number, phase: TacticalPhase) { + const normalized = normalizedPosition(slot.position); + const base = positionCoordinates[normalized] ?? fallbackCoordinates[index % fallbackCoordinates.length]; + const [left, baseTop] = base; + let top = baseTop; + if (phase === "In Possession") { + if (["DR", "DL", "WBR", "WBL", "MR", "ML", "AMR", "AML"].some((code) => normalized.startsWith(code))) top = Math.max(12, top - 5); + if (normalized.startsWith("DM") || normalized.startsWith("MC") || normalized.startsWith("MCR") || normalized.startsWith("MCL")) top = Math.max(18, top - 3); + } else if (phase === "Out of Possession") { + if (!normalized.includes("GK")) top = Math.min(78, top + 5); + if (normalized.startsWith("ST")) top = Math.min(45, top + 9); + } + return [left, top] as const; } function playerById(players: LivePlayer[], playerId: string | null) { return playerId ? players.find((player) => player.id === playerId) ?? null : null; } +function playerCanCoverSlot(player: LivePlayer, slot: LiveTacticSlot) { + const slotPosition = normalizedPosition(slot.position); + const slotFamily = positionFamily(slotPosition); + return player.positions.some((position) => { + const normalized = normalizedPosition(position); + return normalized === slotPosition || positionFamily(normalized) === slotFamily; + }) || player.playableRoles?.some((role) => role.positions.some((position) => normalizedPosition(position) === slotPosition || positionFamily(position) === slotFamily)) === true; +} + +function phaseFit(player: LivePlayer, phase: TacticalPhase) { + if (phase === "In Possession") return player.inPossessionFit ?? player.projectedInPossessionFit ?? null; + if (phase === "Out of Possession") return player.outOfPossessionFit ?? player.projectedOutOfPossessionFit ?? null; + const values = [player.inPossessionFit, player.outOfPossessionFit, player.projectedInPossessionFit, player.projectedOutOfPossessionFit] + .filter((value): value is number => typeof value === "number"); + if (!values.length) return null; + return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length); +} + +function slotFit(player: LivePlayer | null, slot: LiveTacticSlot, phase: TacticalPhase) { + if (!player) return null; + const targetRole = (slot.roleShort ?? slot.role ?? "").toLowerCase(); + const roleMatch = player.playableRoles?.find((role) => { + const roleName = `${role.shortRole} ${role.role}`.toLowerCase(); + return targetRole && roleName.includes(targetRole); + }); + const cover = playerCanCoverSlot(player, slot); + const base = roleMatch?.score ?? phaseFit(player, phase) ?? player.roleFit ?? player.tacticalFit ?? player.efficiencyScore ?? null; + if (base == null) return cover ? 50 : null; + return Math.round(Math.max(0, Math.min(100, cover ? base : base - 18))); +} + +function bestAlternatives(players: LivePlayer[], slot: LiveTacticSlot, phase: TacticalPhase, selectedPlayerId: string | null) { + return players + .filter((player) => player.id !== selectedPlayerId && playerCanCoverSlot(player, slot)) + .map((player) => ({ player, fit: slotFit(player, slot, phase) })) + .filter((item): item is { player: LivePlayer; fit: number } => item.fit != null) + .toSorted((left, right) => right.fit - left.fit || left.player.name.localeCompare(right.player.name)) + .slice(0, 5); +} + export function TacticalBoard({ snapshot, compact = false, @@ -37,14 +133,21 @@ export function TacticalBoard({ onOpenPlayer?: (playerId: string) => void; }) { const [phase, setPhase] = useState("In Possession"); + const [selectedSlotIndex, setSelectedSlotIndex] = useState(0); const slots = snapshot.tactic?.slots ?? []; + const selectedSlot = slots[selectedSlotIndex] ?? slots[0] ?? null; + const selectedPlayer = selectedSlot ? playerById(snapshot.players, selectedSlot.playerId) : null; + const alternatives = useMemo( + () => selectedSlot ? bestAlternatives(snapshot.players, selectedSlot, phase, selectedSlot.playerId) : [], + [phase, selectedSlot, snapshot.players], + ); return (

{compact ? "System overview" : "Tactical board"}

- {!compact ?

Formation and selected XI are read from live FM26 memory. FM26 role and duty masks appear when the packet validates.

: null} + {!compact ?

Selected XI slots come from live FM26 memory. Slot score is recalculated from the player’s mapped position, role and visible attributes.

: null}
{(["In Possession", "Out of Possession", "Combined"] as const).map((item) => ( @@ -64,11 +167,12 @@ export function TacticalBoard({ {slots.map((slot, index) => { const player = playerById(snapshot.players, slot.playerId); - const [left, top] = coordinatesForSlot(slot, index); - const fit = player?.roleFit; - const roleLabel = slot.roleShort ?? slot.role ?? "Role not decoded"; + const [left, top] = coordinatesForSlot(slot, index, phase); + const fit = slotFit(player, slot, phase); + const roleLabel = slot.roleShort ?? slot.role ?? "Role pending"; + const selected = index === selectedSlotIndex; return ( -
) : null}
+ + {selectedSlot ? ( +
+
{selectedSlot.position} slot{selectedSlot.roleShort ?? selectedSlot.role ?? "Role pending"}{selectedSlot.dutyShort || selectedSlot.duty ? ` · ${selectedSlot.dutyShort ?? selectedSlot.duty}` : ""}
+
+ Picked in FM26{selectedPlayer?.name ?? "Unassigned"} + Dynamic slot fit{slotFit(selectedPlayer, selectedSlot, phase) ?? "Unknown"} + {selectedPlayer ? : null} +
+
+ Squad alternatives for this role/position + {alternatives.length ? alternatives.map((item) => ( + + )) :

No mapped squad alternative has enough position/role evidence yet.

} +
+
+ ) : null} ); } diff --git a/src/components/tactics-screen.tsx b/src/components/tactics-screen.tsx index 63ef0d0..d3a19e4 100644 --- a/src/components/tactics-screen.tsx +++ b/src/components/tactics-screen.tsx @@ -4,7 +4,7 @@ import { AlertTriangle, CheckCircle2, Cpu, ShieldCheck } from "lucide-react"; import type { LiveFootballSnapshot } from "@/domain/adapters"; import { TacticalBoard } from "@/components/tactical-board"; -export function TacticsScreen({ snapshot }: { snapshot: LiveFootballSnapshot }) { +export function TacticsScreen({ snapshot, onOpenPlayer }: { snapshot: LiveFootballSnapshot; onOpenPlayer?: (playerId: string) => void }) { const ready = snapshot.tacticSource === "live-memory" && snapshot.tactic != null; const objectDetected = snapshot.status.liveMemoryTacticRead === "object_detected_unmapped"; const tacticObjectDetected = ready || objectDetected; @@ -23,7 +23,7 @@ export function TacticsScreen({ snapshot }: { snapshot: LiveFootballSnapshot })
- +