diff --git a/README.md b/README.md index 048e79c..2cb4fb2 100644 --- a/README.md +++ b/README.md @@ -15,29 +15,31 @@ 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.17` 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. +The native FM26 reader is now split into the Layer 1 core modules: `process.rs`, `permissions.rs`, `memory.rs`, `scanner.rs`, `offsets.rs`, `structs.rs`, `parser.rs` and `validator.rs`. Data-domain and graphics modules are also present for players, clubs, nations, tactics, scout knowledge, face config parsing and logo resolution. -The verified live test indexed 35,874 player records in the active save: 38 managed-squad records were visibility-safe and 35,836 wider-save records remained hidden behind the knowledge gate. Age, attributes, form, contracts, wages, valuations, scout-knowledge links and FM26's own shortlist are not yet mapped safely for this exact build. GlassScout leaves those fields empty and explains the limitation. It does not read or return CA, PA or hidden attributes. +Live-memory tactic inspection cannot block squad/player connection. GlassScout detects the active FM26 tactic manager with read-only access; formation, phase roles, duties and instructions remain unavailable until their packed build-specific layout is validated. No formation or fit result is guessed. + +The verified live test indexed 35,874 player records in the active save: 38 managed-squad records were visibility-safe and 35,836 wider-save records remained behind the knowledge gate. Managed-squad FM ID, name, date of birth, age, nationality, positional familiarity, preferred foot and 47 non-hidden attributes are mapped. Form, contract terms/expiry, wages, valuations, relationship-based scout knowledge, interest, attribute ranges, live tactic slots and FM26's own shortlist remain candidate or unmapped for this exact build. GlassScout leaves those fields empty until validation passes. It does not read or return CA, PA or hidden attributes. GlassScout is live-game only. When the installed FM26 build has no verified entity map, the application shows a clean blocking connection state and does not substitute another data source. ## Desktop product surfaces - Screenshot-matched command dashboard with active-club context, tactical board, recruitment pulse, department briefing and squad health. -- Squad Planner grouped by validated live FM26 position data. -- Tactical Board with a user-selected `.fmf` file, local app-data storage and honest parser status. +- Squad grouped by validated live FM26 position data. +- Tactical Board reserved for validated active-save formation, phase roles, duties and instructions. - Recruitment Hub with visibility, interest, realism, financial, availability and risk fields that remain `Unknown` until real evidence exists. - Player dossiers matching the supplied scouting-report structure without filling unsupported fields. - Shortlist with add/remove, notes, filtering, sorting and comparison. Shortlist records store only player IDs and resolve against the newest live snapshot. -- Advanced diagnostics under Settings with the exact build fingerprint and entity-map status. +- Advanced diagnostics under Settings with the exact build fingerprint, schema-v2 field coverage and candidate/unmapped counts. - Role DNA / Position Converter and transparent true-price estimation from visible attributes and performance. - No alternate data flow, fake team, placeholder player or seeded database. ## 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.17 for Windows x64](https://github.com/TobiasTest22/GlassScout/releases/download/app-v0.1.17/GlassScout.FM26_0.1.17_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. @@ -57,6 +59,17 @@ Live FM26 inspection requires the Tauri desktop runtime: npm run desktop:dev ``` +The local, developer-only Mapping Lab is enabled explicitly and never in the normal product flow: + +```powershell +$env:GLASSSCOUT_MAPPING_MODE = "1" +npm run desktop:dev +``` + +It captures bounded player/person/contract windows to the local app-data `mapping-lab` directory and produces JSON snapshot/diff evidence. It accepts only an indexed FM ID or an exact unique player name, never an arbitrary process address. Candidate offsets remain excluded from live product data until the schema-v2 confidence and validation gate passes. + +For the 0.1.7 validation pass, FM ID `2000478798` (Elias Dale, GK) was captured under FM26's `Interested` and `Doubtful` recruitment filters. The generated comparison found `0` changed and `3072` unchanged bytes across the bounded player, person and contract windows. That is useful negative evidence: player interest is relationship-owned and must not be guessed from the player object. The local JSON snapshots and comparison remain in app data rather than the repository. + The standalone read-only process probe is: ```powershell diff --git a/docs/FM26_MAPPING_STATUS.md b/docs/FM26_MAPPING_STATUS.md new file mode 100644 index 0000000..77cb602 --- /dev/null +++ b/docs/FM26_MAPPING_STATUS.md @@ -0,0 +1,48 @@ +# 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 | +| FM26 role catalogue | Current-build `RoleID` metadata bitmasks | In-possession role masks, duties and out-of-possession role masks extracted from FM26 metadata | +| Player playable-role fit | Position familiarity plus visible attributes | Best/playable/secondary roles are scored from mapped FM26 role metadata, not default position labels | + +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 | +| Live tactic slot role/duty packet offset | Role/duty bitmask catalogues are mapped, but the per-slot tactic packet is only published when it validates against 11 live tactic slots and formation-compatible roles | + +Unknown fields must remain `null` and display as **Unknown**. + +## Active tactic + +The native `TACTICS_MANAGER` and per-save tactic creator object are detected in the active FM26 process using read-only memory access. Formation and selected XI are decoded when the active tactic manager validates. FM26 role, duty and out-of-possession role metadata masks are now catalogued for this build. + +GlassScout now scans candidate tactic packets for those real FM26 bitmasks. A role/duty packet is only published when all 11 role masks resolve and the majority are compatible with the live formation slots. If the packet does not validate, GlassScout still shows the formation/selected XI and keeps role/duty labels pending rather than inventing them. diff --git a/package-lock.json b/package-lock.json index e4ca0aa..ce4c8f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { "name": "glassscout-fm26", - "version": "0.1.4", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "glassscout-fm26", - "version": "0.1.4", + "version": "0.1.17", "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/inter": "^5.2.8", "@tauri-apps/api": "^2.11.1", - "@tauri-apps/plugin-dialog": "^2.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.24", @@ -3181,15 +3180,6 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", - "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", diff --git a/package.json b/package.json index 3fffff5..d430028 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glassscout-fm26", - "version": "0.1.4", + "version": "0.1.17", "private": true, "scripts": { "dev": "next dev", @@ -16,7 +16,6 @@ "@base-ui/react": "^1.6.0", "@fontsource-variable/inter": "^5.2.8", "@tauri-apps/api": "^2.11.1", - "@tauri-apps/plugin-dialog": "^2.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.24", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cc76ad0..297ab63 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,14 +1305,15 @@ dependencies = [ [[package]] name = "glassscout-fm26" -version = "0.1.4" +version = "0.1.17" dependencies = [ + "base64 0.22.1", + "rusqlite", "serde", "serde_json", "sha2", "tauri", "tauri-build", - "tauri-plugin-dialog", "tauri-plugin-sql", ] @@ -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" @@ -2297,7 +2340,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", - "libc", "objc2", "objc2-core-foundation", ] @@ -2887,30 +2929,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - [[package]] name = "rkyv" version = "0.7.46" @@ -2960,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" @@ -3453,7 +3485,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap 2.14.0", "log", "memchr", @@ -3933,48 +3965,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "tauri-plugin-dialog" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.18", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" -dependencies = [ - "anyhow", - "dunce", - "glob", - "log", - "objc2-foundation", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", - "url", -] - [[package]] name = "tauri-plugin-sql" version = "2.4.0" @@ -5070,15 +5060,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5127,30 +5108,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -5187,12 +5151,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5211,12 +5169,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5235,24 +5187,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5271,12 +5211,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5295,12 +5229,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5319,12 +5247,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5343,12 +5265,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 09ece78..31ef089 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.17" description = "Read-only desktop recruitment workspace for Football Manager 2026" authors = ["GlassScout"] edition = "2021" @@ -13,9 +13,10 @@ 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" +rusqlite = { version = "0.32", features = ["bundled"] } tauri = { version = "2", features = [] } -tauri-plugin-dialog = "2.7.1" tauri-plugin-sql = { version = "2", features = ["sqlite"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 623b9bc..b8de30a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,7 +5,6 @@ "windows": ["main"], "permissions": [ "core:default", - "dialog:allow-open", "sql:default", "sql:allow-load", "sql:allow-select", diff --git a/src-tauri/entity-maps/index.json b/src-tauri/entity-maps/index.json index f1095b6..2525c45 100644 --- a/src-tauri/entity-maps/index.json +++ b/src-tauri/entity-maps/index.json @@ -1,8 +1,15 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "profiles": [ { "id": "fm26-6000.0.52.8888375-win64-3653c97f", + "buildFingerprint": { + "executableSha256": "3653C97F9CCEC2BE28EDC4FAAE67304B5B6C26733F2F07DEA3E7C591D3B9FF73", + "fileVersion": "6000.0.52.8888375", + "productVersion": "6000.0.52f1-fm26-05f1 (87a0370e9917)", + "architecture": "x64", + "module": "game_plugin.dll" + }, "fileVersion": "6000.0.52.8888375", "productVersion": "6000.0.52f1-fm26-05f1 (87a0370e9917)", "executableSha256": "3653C97F9CCEC2BE28EDC4FAAE67304B5B6C26733F2F07DEA3E7C591D3B9FF73", @@ -15,16 +22,8 @@ } ], "pointerChains": [ - { - "name": "managed_team", - "root": "human_manager", - "offsets": [1104, 168, 16] - }, - { - "name": "managed_club", - "root": "managed_team", - "offsets": [48] - } + { "name": "managed_team", "root": "human_manager", "offsets": [1104, 168, 16] }, + { "name": "managed_club", "root": "managed_team", "offsets": [48] } ], "constants": { "managerRegistryVectorOffset": 24, @@ -42,7 +41,48 @@ "clubNameOffset": 192, "entityUidOffset": 12, "playerPersonOffset": 648, - "playerPositionsOffset": 336 + "playerPositionsOffset": 336, + "playerAttributesOffset": 351, + "playerCurrentDateOffset": 512, + "personNationalityOffset": 104, + "nationNameOffset": 24, + "personBirthDateOffset": 136, + "tacticsManagerVtableRva": 74061456 + }, + "sections": { + "player": { + "uniqueId": { "offset": 12, "source": "person", "valueType": "u32", "transform": "decimal-string", "status": "validated", "confidence": 1.0, "validations": ["38 managed players", "35874 indexed records", "stable across repeated reads"] }, + "name": { "offset": 80, "source": "person", "valueType": "fm-string-pointer", "transform": "first-common-last", "status": "validated", "confidence": 1.0, "validations": ["managed squad names match FM26", "bounded UTF-8 checks"] }, + "dateOfBirth": { "offset": 136, "source": "person", "valueType": "fm-date", "transform": "year-day-of-year", "status": "validated", "confidence": 1.0, "validations": ["Lars Sveingard 1995-08-13", "age matches active save date"] }, + "nationality": { "offset": 104, "source": "person", "valueType": "pointer", "transform": "nation-name@24", "status": "validated", "confidence": 0.98, "validations": ["managed squad nationality labels match FM26"] }, + "positions": { "offset": 336, "source": "player", "valueType": "u8[15]", "transform": "familiarity-enum", "status": "validated", "confidence": 1.0, "validations": ["all managed players pass 0-20 bounds", "position labels match FM26"] }, + "attributes": { "offset": 351, "source": "player", "valueType": "u8[54]", "transform": "(raw+4)/5; hidden indexes gated", "status": "validated", "confidence": 0.99, "validations": ["all managed players pass 0-100 bounds", "visible values checked in FM26", "hidden attributes excluded"] }, + "preferredFoot": { "offset": 375, "source": "player", "valueType": "u8[2]", "transform": "qualitative-foot-label", "status": "validated", "confidence": 0.97, "validations": ["left/right foot evidence matches managed profiles"] }, + "form": { "offset": null, "source": "player", "valueType": "unknown", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] } + }, + "contract": { + "contractPointer": { "offset": 168, "source": "person", "valueType": "pointer", "transform": "non-null active contract", "status": "validated", "confidence": 1.0, "validations": ["manager contract resolves managed team", "player contract objects readable"] }, + "wage": { "offset": null, "source": "contract", "valueType": "currency", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] }, + "expiryDate": { "offset": null, "source": "contract", "valueType": "fm-date", "transform": "year-day-of-year", "status": "unmapped", "confidence": 0.0, "validations": [] }, + "status": { "offset": null, "source": "contract", "valueType": "enum", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] }, + "marketValue": { "offset": null, "source": "player-or-contract", "valueType": "currency-range", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] } + }, + "recruitment": { + "scoutKnowledge": { "offset": null, "source": "game-scouted-person relation", "valueType": "enum-or-bitset", "transform": null, "status": "candidate", "confidence": 0.25, "validations": ["GAME_SCOUTED_PERSON RTTI located", "relationship owner not yet validated"] }, + "attributeRanges": { "offset": null, "source": "game-scouted-person relation", "valueType": "range[54]", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] }, + "scoutConfidence": { "offset": null, "source": "scout-report relation", "valueType": "percentage", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] }, + "playerInterest": { "offset": null, "source": "club-person relation", "valueType": "enum", "transform": null, "status": "candidate", "confidence": 0.25, "validations": ["FM26 Interested/Doubtful filter states captured for player 2000478798", "3072 player/person/contract bytes remained unchanged, confirming the value is relationship-owned", "relation owner not yet validated"] }, + "transferAvailability": { "offset": null, "source": "contract-or-transfer relation", "valueType": "flags", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] } + }, + "tactic": { + "managerObject": { "offset": 74061456, "source": "module-vtable-rva", "valueType": "vtable", "transform": "unique private object scan", "status": "validated", "confidence": 0.92, "validations": ["TACTICS_MANAGER RTTI matched", "unique object observed in active save", "selected XI block resolves managed-player person pointers"] }, + "formation": { "offset": 980, "source": "tactics-manager", "valueType": "u32 enum", "transform": "FM26 metadata formation catalogue; code 4 -> Formation433DMWide -> 4-1-2-3 DM Wide", "status": "validated", "confidence": 0.9, "validations": ["offset 0x03D4 read code 4 while FM26 UI displayed 4-1-2-3 DM Wide", "global-metadata.dat formation enum names extracted for this build"] }, + "selectedPlayers": { "offset": 17464, "source": "tactics-manager+selection-pointer", "valueType": "person-pointer[11]", "transform": "pointer at tactic+0x41B8, then selected person array at +0x1B00", "status": "validated", "confidence": 0.93, "validations": ["11 consecutive active tactic person pointers", "all resolved against managed squad player records", "zero terminator after slot 11"] }, + "roles": { "offset": null, "source": "tactics-manager plus FM26 metadata RoleID bitmasks", "valueType": "u64 bitmask packet", "transform": "validate 11 slot masks against FM26 role catalogue and formation-compatible positions before publishing", "status": "candidate", "confidence": 0.55, "validations": ["FM26 global-metadata.dat role masks extracted", "GlassScout rejects role packets unless 11 role masks resolve and at least 7 are position-compatible"] }, + "duties": { "offset": null, "source": "tactics-manager plus FM26 metadata duty bitmasks", "valueType": "u64 bitmask packet", "transform": "validate defend/support/attack/stopper/cover/float masks before publishing", "status": "candidate", "confidence": 0.45, "validations": ["FM26 global-metadata.dat duty masks extracted", "duties are only displayed when the live packet resolves per slot"] }, + "outOfPossessionRoles": { "offset": null, "source": "FM26 metadata OOPRoleID bitmasks", "valueType": "u64 bitmask catalogue", "transform": "catalogue mapped; live phase layout packet still requires validation", "status": "candidate", "confidence": 0.5, "validations": ["39 out-of-possession role masks extracted from current FM26 metadata"] }, + "teamInstructions": { "offset": null, "source": "tactics-manager", "valueType": "flags", "transform": null, "status": "unmapped", "confidence": 0.0, "validations": [] } + } } } ] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..6941851 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,17 @@ +pub(crate) const REGISTERED_COMMANDS: &[&str] = &[ + "connector_status", + "connector_snapshot", + "load_active_save", + "search_indexed_players", + "indexed_players_by_ids", + "mapping_lab_status", + "mapping_lab_capture", + "mapping_lab_compare", + "club_logo_data", + "player_face_data", + "filter_observations", +]; + +pub(crate) fn registered_commands() -> &'static [&'static str] { + REGISTERED_COMMANDS +} diff --git a/src-tauri/src/connector.rs b/src-tauri/src/connector.rs index ac60679..b479f7f 100644 --- a/src-tauri/src/connector.rs +++ b/src-tauri/src/connector.rs @@ -1,7 +1,6 @@ -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use tauri::Emitter; use std::{ collections::{HashMap, HashSet}, fs::File, @@ -9,12 +8,27 @@ 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", -]; +use crate::{ + data::players::{IndexedPlayerRecord, PlayerDatabaseIndex}, + fm26::{ + memory::{ModuleInfo, ProcessReader}, + offsets::{find_entity_map, mapping_coverage, EntityMapProfile, MappingCoverage}, + parser::{preferred_foot_label, visible_attribute_map}, + permissions::{can_write_memory, READ_ONLY_PROCESS_ACCESS_LABEL}, + process::find_fm26_process, + roles::{ + decode_role_duty_mask, duty_definition_for_mask, evaluate_player_roles, + role_catalogue_status, role_definition_for_mask, role_supports_slot, + }, + scanner::{parse_pattern, scan_module, scan_private_memory_for_pointers}, + structs::{FmDate, PLAYER_ATTRIBUTE_NAMES, POSITION_NAMES}, + tactics::tactic_formation_template, + validator, + }, + fm_dossier, +}; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -47,6 +61,8 @@ pub struct ConnectorStatus { module_base: Option, entity_map_status: &'static str, entity_map_profile_id: Option, + mapping_schema_version: u32, + mapping_coverage: Vec, pointer_validation: &'static str, handle_access_flags: &'static str, entity_root: Option, @@ -54,6 +70,7 @@ pub struct ConnectorStatus { managed_club_pointer: Option, player_collection_pointer: Option, live_memory_tactic_read: &'static str, + tactic_manager_pointer: Option, failure_stage: Option, last_successful_read: Option, windows_error_code: Option, @@ -72,9 +89,6 @@ pub struct ConnectorSnapshot { players: Vec, tactic: Option, tactic_source: &'static str, - tactic_file_status: &'static str, - tactic_file_name: Option, - tactic_file_warnings: Vec, data_error: Option, data_source: &'static str, data_warnings: Vec, @@ -88,61 +102,6 @@ struct ExecutableIdentity { architecture: Option, } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EntityMapIndex { - schema_version: u32, - profiles: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EntityMapProfile { - id: String, - file_version: String, - product_version: String, - executable_sha256: String, - architecture: String, - module: String, - signatures: Vec, - pointer_chains: Vec, - constants: MapConstants, -} - -#[derive(Deserialize)] -struct EntitySignature { - name: String, - pattern: String, -} - -#[derive(Deserialize)] -struct PointerChain { - name: String, - root: String, - offsets: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct MapConstants { - manager_registry_vector_offset: u64, - human_person_offset: u64, - person_first_name_offset: u64, - person_second_name_offset: u64, - person_common_name_offset: u64, - person_contract_offset: u64, - contract_team_offset: u64, - team_vtable_rva: u64, - team_club_offset: u64, - team_players_start_offset: u64, - team_players_end_offset: u64, - club_vtable_rva: u64, - club_name_offset: u64, - entity_uid_offset: u64, - player_person_offset: u64, - player_positions_offset: u64, -} - #[derive(Default)] struct ExtractionDiagnostics { entity_root: Option, @@ -155,29 +114,16 @@ struct ExtractionDiagnostics { struct LiveData { managed_club_id: String, manager_name: String, + season: Option, clubs: Vec, players: Vec, + tactic: Option, database_players_indexed: u32, background_players_indexed: u32, database_index_status: &'static str, database_scope: &'static str, warnings: Vec, -} - -#[derive(Clone)] -struct IndexedPlayerRecord { - id: String, - name: String, - positions: Vec, - managed_squad: bool, - visibility_safe: bool, -} - -#[derive(Default)] -struct PlayerDatabaseIndex { - process_id: u32, - save_pointer: u64, - records: HashMap, + tactic_manager_pointer: Option, } static PLAYER_DATABASE_INDEX: OnceLock> = OnceLock::new(); @@ -218,10 +164,11 @@ pub fn load_active_save(app: tauri::AppHandle) -> ConnectorSnapshot { #[tauri::command] pub fn search_indexed_players(query: String) -> Vec { - let normalized = query.trim().to_lowercase(); - if normalized.is_empty() { - return Vec::new(); + 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(); }; @@ -229,30 +176,165 @@ pub fn search_indexed_players(query: String) -> Vec { return Vec::new(); }; let _identity = (index.process_id, index.save_pointer); - index + let mut results: Vec = index .records .values() - .filter(|record| record.visibility_safe) .filter(|record| { - record.name.to_lowercase().contains(&normalized) + normalized.is_empty() + || record.name.to_lowercase().contains(&normalized) || record .positions .iter() .any(|position| position.to_lowercase().contains(&normalized)) }) - .take(100) - .map(|record| { - json!({ - "id": record.id, - "name": record.name, - "positions": record.positions, - "managedSquad": record.managed_squad, - "visibility": "club-visible" - }) - }) + .map(indexed_player_json) + .collect(); + results.sort_by(|left, right| { + left["name"] + .as_str() + .unwrap_or_default() + .cmp(right["name"].as_str().unwrap_or_default()) + }); + results.truncate(500); + results +} + +#[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(); + }; + let Ok(index) = index.read() else { + return Vec::new(); + }; + player_ids + .iter() + .filter_map(|id| index.records.get(id)) + .map(indexed_player_json) .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, + "name": record.name, + "positions": record.positions, + "managedSquad": record.managed_squad, + "visibility": if record.visibility_safe { "known" } else { "unknown" }, + "scoutKnowledge": if record.visibility_safe { "fully_known" } else { "unknown" }, + "scoutConfidence": if record.visibility_safe { 100 } else { 0 } + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MappingLabWindow { + pub(crate) object: &'static str, + pub(crate) base_address: String, + pub(crate) bytes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MappingLabCaptureData { + pub(crate) player_id: String, + pub(crate) player_name: String, + pub(crate) process_id: u32, + pub(crate) save_pointer: String, + pub(crate) entity_map_profile_id: String, + pub(crate) executable_sha256: String, + pub(crate) windows: Vec, +} + +#[cfg(target_os = "windows")] +pub(crate) fn capture_mapping_lab_player( + player_id: &str, + window_size: usize, +) -> Result { + let window_size = window_size.clamp(64, 4096); + let index = PLAYER_DATABASE_INDEX + .get() + .ok_or_else(|| "Load the active save before capturing mapping evidence.".to_string())? + .read() + .map_err(|_| "The live player index is unavailable.".to_string())?; + let record = index + .records + .get(player_id) + .cloned() + .or_else(|| { + let mut matches = index + .records + .values() + .filter(|record| record.name.eq_ignore_ascii_case(player_id)); + let first = matches.next()?.clone(); + matches.next().is_none().then_some(first) + }) + .ok_or_else(|| { + "No unique indexed player matched that FM ID or exact player name.".to_string() + })?; + let process_id = index.process_id; + let save_pointer = index.save_pointer; + drop(index); + + let mut reader = ProcessReader::open(process_id) + .map_err(|code| format!("Read-only FM26 access failed with Windows error {code}."))?; + let process_path = reader + .process_path() + .ok_or_else(|| "The FM26 executable path could not be read.".to_string())?; + let identity = read_executable_identity(&process_path); + let profile = find_entity_map( + identity.file_version.as_deref(), + identity.product_version.as_deref(), + identity.sha256.as_deref(), + identity.architecture.as_deref(), + ) + .ok_or_else(|| "The running FM26 build does not match an exact entity map.".to_string())?; + let mut targets = vec![ + ("player", record.raw_player_address), + ("person", record.person_address), + ]; + if let Some(contract) = record.contract_address { + targets.push(("contract", contract)); + } + let mut windows = Vec::with_capacity(targets.len()); + for (object, address) in targets { + let bytes = reader + .read_bytes(address, window_size) + .ok_or_else(|| format!("The bounded {object} window was not readable."))?; + windows.push(MappingLabWindow { + object, + base_address: hex_address(address), + bytes, + }); + } + Ok(MappingLabCaptureData { + player_id: record.id, + player_name: record.name, + process_id, + save_pointer: hex_address(save_pointer), + entity_map_profile_id: profile.id.clone(), + executable_sha256: identity.sha256.unwrap_or_default(), + windows, + }) +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn capture_mapping_lab_player( + _player_id: &str, + _window_size: usize, +) -> Result { + Err("The FM26 mapping lab requires Windows.".to_string()) +} + fn empty_status() -> ConnectorStatus { ConnectorStatus { process_detected: false, @@ -275,7 +357,7 @@ fn empty_status() -> ConnectorStatus { last_sync: None, bytes_read: 0, executable_header_valid: false, - can_write_memory: false, + can_write_memory: can_write_memory(), game_build: None, product_version: None, executable_sha256: None, @@ -283,13 +365,16 @@ fn empty_status() -> ConnectorStatus { module_base: None, entity_map_status: "not_checked", entity_map_profile_id: None, + mapping_schema_version: 2, + mapping_coverage: Vec::new(), pointer_validation: "not_run", - handle_access_flags: "PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ (0x1410)", + handle_access_flags: READ_ONLY_PROCESS_ACCESS_LABEL, entity_root: None, save_pointer: None, managed_club_pointer: None, player_collection_pointer: None, - live_memory_tactic_read: "disabled", + live_memory_tactic_read: "not_run", + tactic_manager_pointer: None, failure_stage: None, last_successful_read: None, windows_error_code: None, @@ -308,9 +393,6 @@ fn empty_snapshot(status: ConnectorStatus, error: String) -> ConnectorSnapshot { players: Vec::new(), tactic: None, tactic_source: "none", - tactic_file_status: "not_imported", - tactic_file_name: None, - tactic_file_warnings: Vec::new(), data_error: Some(error), data_source: "none", data_warnings: Vec::new(), @@ -363,7 +445,12 @@ fn collect_snapshot( status.executable_sha256 = identity.sha256.clone(); status.architecture = identity.architecture.clone(); - let Some(profile) = find_entity_map(&identity) else { + let Some(profile) = find_entity_map( + identity.file_version.as_deref(), + identity.product_version.as_deref(), + identity.sha256.as_deref(), + identity.architecture.as_deref(), + ) else { status.state = "parser_unverified"; status.entity_map_status = "missing"; status.failure_stage = Some("exact_build_match".to_string()); @@ -377,6 +464,7 @@ fn collect_snapshot( }; status.entity_map_status = "matched"; status.entity_map_profile_id = Some(profile.id.clone()); + status.mapping_coverage = mapping_coverage(profile); let Some(module) = reader.module(&profile.module) else { status.state = "parser_unverified"; @@ -436,12 +524,20 @@ 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; status.clubs_loaded = data.clubs.len() as u32; status.pointer_validation = "passed"; + status.live_memory_tactic_read = if data.tactic.is_some() { + "ready" + } else if data.tactic_manager_pointer.is_some() { + "object_detected_unmapped" + } else { + "object_not_found" + }; + status.tactic_manager_pointer = data.tactic_manager_pointer.map(hex_address); status.last_sync = Some(unix_milliseconds()); status.message = format!( "Connected to {} with {} live squad players and {} indexed player records.", @@ -458,14 +554,15 @@ 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, - tactic_source: "none", - tactic_file_status: "not_imported", - tactic_file_name: None, - tactic_file_warnings: Vec::new(), + tactic_source: if data.tactic.is_some() { + "live-memory" + } else { + "none" + }, + tactic: data.tactic, data_error: None, data_source: "live-memory", data_warnings: data.warnings, @@ -511,8 +608,11 @@ fn extract_live_data( .ok_or_else(|| { ExtractionFailure::new("manager_signature", "The exact build map is incomplete.") })?; - let pattern = parse_pattern(&signature.pattern)?; - let hits = reader.scan_module(module, &pattern)?; + let pattern = parse_pattern(&signature.pattern).map_err(|_| { + ExtractionFailure::new("entity_map", "The embedded signature pattern is invalid.") + })?; + let hits = scan_module(reader, module, &pattern) + .map_err(|error| ExtractionFailure::new("manager_signature", error.to_string()))?; if hits.len() != 1 { return Err(ExtractionFailure::new( "manager_signature", @@ -605,12 +705,12 @@ fn extract_live_data( "The managed FM26 team could not be resolved.", ) })?; - validate_vtable( + validator::validate_vtable( reader, team, module.base + profile.constants.team_vtable_rva, - "managed_team", - )?; + ) + .map_err(|message| ExtractionFailure::new("managed_team", message))?; let club = reader .read_pointer(team + profile.constants.team_club_offset) .filter(|value| *value != 0) @@ -620,12 +720,12 @@ fn extract_live_data( "The managed FM26 club could not be resolved.", ) })?; - validate_vtable( + validator::validate_vtable( reader, club, module.base + profile.constants.club_vtable_rva, - "managed_club", - )?; + ) + .map_err(|message| ExtractionFailure::new("managed_club", message))?; diagnostics.managed_club_pointer = Some(club); diagnostics.last_successful_read = Some("validate_managed_club".to_string()); if let Some(progress) = progress { @@ -761,8 +861,82 @@ 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 role_evaluation = evaluate_player_roles(&position_bytes, &visible_attributes); + let best_role = role_evaluation + .best + .as_ref() + .map(|fit| fit.role.to_string()); + let ability_score = role_evaluation + .best + .as_ref() + .map(|fit| fit.score) + .or_else(|| { + calculated_position + .as_deref() + .and_then(|position| visible_ability_score(position, &visible_attributes)) + }); + let (strengths, weaknesses) = attribute_evidence(&visible_attributes); + let validated_at = unix_milliseconds(); + let attribute_knowledge: serde_json::Map = visible_attributes + .iter() + .map(|(name, value)| { + ( + name.clone(), + json!({ + "value": value, + "visibility": "known", + "source": "own-squad", + "confidence": 100, + "lastValidated": validated_at + }), + ) + }) + .collect(); + let playable_roles = role_evaluation.playable; + let other_roles = role_evaluation.secondary; + let role_reasoning = role_evaluation.reasoning; let player_id = uid.to_string(); + let contract_address = reader + .read_pointer(person + profile.constants.person_contract_offset) + .filter(|value| *value != 0); managed_player_ids.insert(player_id.clone()); managed_index_records.push(IndexedPlayerRecord { id: player_id.clone(), @@ -770,16 +944,23 @@ fn extract_live_data( positions: positions.clone(), managed_squad: true, visibility_safe: true, + raw_player_address: raw_player, + person_address: person, + contract_address, }); players.push(json!({ "id": player_id, - "name": name, - "age": null, - "nationality": null, - "positions": positions, - "bestRole": null, + "name": name.clone(), + "_season": season, + "age": age, + "dateOfBirth": date_of_birth, + "nationality": nationality.clone(), + "secondNationality": null, + "positions": positions.clone(), + "bestRole": best_role, "currentAbility": null, "potentialAbility": null, + "abilityScore": ability_score, "form": null, "averageRating": null, "minutesPlayed": null, @@ -791,99 +972,177 @@ fn extract_live_data( "squadImportance": null, "developmentTrend": null, "tacticalFit": null, - "roleFit": null, - "strengths": [], - "weaknesses": [], + "roleFit": ability_score, + "playableRoles": playable_roles, + "otherRoles": other_roles, + "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_reasoning, "riskLevel": "unknown", "marketValueAmount": null + ,"personality": null + ,"condition": null + ,"recommendation": { + "minimum": ability_score, + "maximum": ability_score, + "completeness": 100, + "label": if ability_score.is_some() { "full visible-attribute evidence" } else { "not enough evidence" } + } + ,"knowledge": { + "name": { "value": name, "visibility": "known", "source": "own-squad", "confidence": 100, "lastValidated": validated_at }, + "age": { "value": age, "visibility": "known", "source": "own-squad", "confidence": 100, "lastValidated": validated_at }, + "nationality": { "value": nationality, "visibility": "known", "source": "own-squad", "confidence": 100, "lastValidated": validated_at }, + "positions": { "value": positions, "visibility": "known", "source": "own-squad", "confidence": 100, "lastValidated": validated_at }, + "attributes": attribute_knowledge, + "form": { "value": null, "visibility": "unknown", "source": "memory-raw", "confidence": 0, "lastValidated": null }, + "contract": { "value": null, "visibility": "unknown", "source": "memory-raw", "confidence": 0, "lastValidated": null }, + "wage": { "value": null, "visibility": "unknown", "source": "memory-raw", "confidence": 0, "lastValidated": null }, + "value": { "value": null, "visibility": "unknown", "source": "memory-raw", "confidence": 0, "lastValidated": null }, + "interest": { "value": null, "visibility": "unknown", "source": "memory-raw", "confidence": 0, "lastValidated": null } + } })); } diagnostics.last_successful_read = Some("extract_managed_squad".to_string()); + let managed_tactic_records = managed_index_records.clone(); - 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, + tactic_manager_pointer, + ) = 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, + module, + &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", + indexed.tactic_manager_pointer, + ) } - } 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", None), + } + } else { + store_player_index( + process_id, + diagnostics.save_pointer.unwrap_or_default(), + managed_index_records, + ); + (player_count as u32, 0, "not_run", "managed-squad", None) + }; + 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 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![ - "Player names and positional familiarity are live; age, attributes, form, contract, value, wage and performance fields remain unavailable for this exact map.".to_string(), - "Live-memory tactic reading is disabled. Tactic Evaluation uses only a user-selected local FMF file.".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(), + "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 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 { + "The live FM26 tactic manager is detected with read-only access, but the selected-slot block did not validate for this read. No tactic is guessed.".to_string() + }, "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, @@ -893,28 +1152,427 @@ fn extract_live_data( Ok(LiveData { managed_club_id: club_uid.to_string(), manager_name, + season, clubs, players, + tactic, database_players_indexed, background_players_indexed, database_index_status, database_scope, warnings, + tactic_manager_pointer, + }) +} + +#[cfg(target_os = "windows")] +const TACTIC_FORMATION_CODE_OFFSET: u64 = 0x03D4; +#[cfg(target_os = "windows")] +const TACTIC_SELECTION_POINTER_OFFSETS: [u64; 3] = [0x41B8, 0x41C0, 0x41C8]; + +#[cfg(target_os = "windows")] +fn extract_live_tactic( + reader: &mut ProcessReader, + tactic_manager_pointer: u64, + managed_records: &[IndexedPlayerRecord], +) -> Option { + let formation_code = reader.read_u32(tactic_manager_pointer + TACTIC_FORMATION_CODE_OFFSET)?; + let template = tactic_formation_template(formation_code)?; + let selection = find_live_tactic_selection( + reader, + tactic_manager_pointer, + managed_records, + template.positions.len(), + )?; + let managed_by_person = managed_records + .iter() + .map(|record| (record.person_address, record)) + .collect::>(); + let mut slots = Vec::with_capacity(template.positions.len()); + let role_packet = find_live_tactic_role_packet( + reader, + tactic_manager_pointer, + &selection, + template.positions, + ); + + for (index, position) in template.positions.iter().enumerate() { + let person_pointer = selection.person_pointers[index]; + let record = managed_by_person.get(&person_pointer).copied(); + let role_slot = role_packet + .as_ref() + .and_then(|packet| packet.slots.get(index)) + .copied(); + slots.push(json!({ + "playerId": record.map(|record| record.id.clone()), + "position": position, + "role": role_slot.and_then(|slot| slot.role_label), + "roleShort": role_slot.and_then(|slot| slot.role_short_label), + "roleMask": role_slot.and_then(|slot| slot.role_mask).map(|mask| format!("0x{mask:X}")), + "duty": role_slot.and_then(|slot| slot.duty_label), + "dutyShort": role_slot.and_then(|slot| slot.duty_short_label), + "dutyMask": role_slot.and_then(|slot| slot.duty_mask).map(|mask| format!("0x{mask:X}")), + "personPointer": if person_pointer == 0 { None } else { Some(hex_address(person_pointer)) }, + "decoderStatus": match (record.is_some(), role_slot.and_then(|slot| slot.role_label).is_some(), role_slot.and_then(|slot| slot.duty_label).is_some()) { + (true, true, true) => "player-role-duty-validated", + (true, true, false) => "player-role-validated-duty-pending", + (true, false, _) => "player-slot-validated-role-pending", + _ => "player-slot-unresolved" + } + })); + } + + let role_warning = match &role_packet { + Some(packet) if packet.duties_resolved == template.positions.len() => { + "Packed FM26 role and duty masks validated against the active tactic slots." + } + Some(_) => { + "Packed FM26 role masks validated against the active tactic slots; duty masks remain pending for this read." + } + None => { + "Packed role, duty, phase-layout and instruction codes did not validate on this read, so GlassScout does not invent them." + } + }; + let warnings = if template.exact_shape { + vec![ + "Formation and selected XI slots are decoded from live FM26 memory.", + role_warning, + ] + } else { + vec![ + "The FM26 formation code is known, but this formation's exact pitch slot template is not validated yet.", + "Selected XI is decoded from live FM26 memory; pitch placement uses neutral slot labels until the formation template is mapped.", + role_warning, + ] + }; + Some(json!({ + "name": null, + "formation": template.label, + "formationEnum": template.enum_name, + "formationCode": formation_code, + "slots": slots, + "teamInstructions": [], + "playerInstructionsReadable": false, + "decoderStatus": match (&role_packet, template.exact_shape) { + (Some(packet), true) if packet.duties_resolved == template.positions.len() => "formation-selected-xi-role-duty-validated", + (Some(_), true) => "formation-selected-xi-role-validated", + (None, true) => "formation-shape-and-selected-xi-validated", + (Some(_), false) => "selected-xi-role-validated-template-pending", + (None, false) => "selected-xi-validated-template-pending", + }, + "layoutStatus": if template.exact_shape { "exact-template" } else { "formation-name-only" }, + "selectionPointer": hex_address(selection.base_address + selection.offset), + "selectionStride": selection.stride, + "selectionResolvedPlayers": selection.resolved_players, + "roleDutyDecoderStatus": role_packet.as_ref().map(|packet| packet.status).unwrap_or("packet-not-validated"), + "rolePacketPointer": role_packet.as_ref().map(|packet| hex_address(packet.base_address + packet.offset)), + "rolePacketStride": role_packet.as_ref().map(|packet| packet.stride), + "rolePacketWidth": role_packet.as_ref().map(|packet| packet.width), + "rolesResolved": role_packet.as_ref().map(|packet| packet.roles_resolved).unwrap_or_default(), + "dutiesResolved": role_packet.as_ref().map(|packet| packet.duties_resolved).unwrap_or_default(), + "roleCatalogue": role_catalogue_status(), + "warnings": warnings + })) +} + +#[cfg(target_os = "windows")] +struct TacticSelection { + base_address: u64, + offset: u64, + stride: u64, + person_pointers: Vec, + resolved_players: usize, +} + +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +struct TacticRoleSlot { + role_label: Option<&'static str>, + role_short_label: Option<&'static str>, + role_mask: Option, + duty_label: Option<&'static str>, + duty_short_label: Option<&'static str>, + duty_mask: Option, +} + +#[cfg(target_os = "windows")] +struct TacticRolePacket { + base_address: u64, + offset: u64, + stride: u64, + width: u64, + status: &'static str, + slots: Vec, + roles_resolved: usize, + duties_resolved: usize, + compatible_roles: usize, +} + +#[cfg(target_os = "windows")] +fn find_live_tactic_role_packet( + reader: &mut ProcessReader, + tactic_manager_pointer: u64, + selection: &TacticSelection, + positions: &[&str], +) -> Option { + let mut candidates = vec![tactic_manager_pointer, selection.base_address]; + if let Some(bytes) = reader.read_bytes(tactic_manager_pointer, 0x2000) { + for offset in (0..bytes.len().saturating_sub(8)).step_by(8) { + let pointer = u64::from_le_bytes(bytes[offset..offset + 8].try_into().ok()?); + if plausible_process_pointer(pointer) { + candidates.push(pointer); + } + } + } + candidates.sort_unstable(); + candidates.dedup(); + + let mut best: Option = None; + for base_address in candidates { + let Some(bytes) = read_bounded_window(reader, base_address) else { + continue; + }; + for width in [8_u64, 4_u64] { + for stride in [width, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x48, 0x50] { + if stride < width { + continue; + } + let needed = (positions.len().saturating_sub(1) as u64) + .saturating_mul(stride) + .saturating_add(width) as usize; + if bytes.len() < needed { + continue; + } + let max_start = bytes.len().saturating_sub(needed); + for start in (0..=max_start).step_by(4) { + let Some(packet) = decode_role_packet_at( + base_address, + &bytes, + start, + stride, + width, + positions, + ) else { + continue; + }; + let replace = best.as_ref().is_none_or(|current| { + packet.roles_resolved > current.roles_resolved + || (packet.roles_resolved == current.roles_resolved + && packet.duties_resolved > current.duties_resolved) + || (packet.roles_resolved == current.roles_resolved + && packet.duties_resolved == current.duties_resolved + && packet.compatible_roles > current.compatible_roles) + }); + if replace { + best = Some(packet); + } + } + } + } + } + best +} + +#[cfg(target_os = "windows")] +fn decode_role_packet_at( + base_address: u64, + bytes: &[u8], + start: usize, + stride: u64, + width: u64, + positions: &[&str], +) -> Option { + let mut slots = Vec::with_capacity(positions.len()); + let mut roles_resolved = 0_usize; + let mut duties_resolved = 0_usize; + let mut compatible_roles = 0_usize; + let mut distinct_roles = HashSet::new(); + let mut distinct_duties = HashSet::new(); + + for (index, position) in positions.iter().enumerate() { + let offset = start + (index as u64 * stride) as usize; + let value = read_packet_value(bytes, offset, width)?; + let decoded = decode_role_duty_mask(value); + if decoded.unknown_bits != 0 { + return None; + } + let role = decoded + .role + .or_else(|| role_definition_for_mask(value & u64::from(u32::MAX))); + let duty = decoded + .duty + .or_else(|| duty_definition_for_mask(value & u64::from(u32::MAX))); + if let Some(role) = role { + roles_resolved += 1; + distinct_roles.insert(role.key); + if role_supports_slot(role, position) { + compatible_roles += 1; + } + } + if let Some(duty) = duty { + duties_resolved += 1; + distinct_duties.insert(duty.key); + } + if role.is_none() && duty.is_none() { + return None; + } + slots.push(TacticRoleSlot { + role_label: role.map(|definition| definition.label), + role_short_label: role.map(|definition| definition.short_label), + role_mask: role.map(|definition| definition.mask), + duty_label: duty.map(|definition| definition.label), + duty_short_label: duty.map(|definition| definition.short_label), + duty_mask: duty.map(|definition| definition.mask), + }); + } + + if roles_resolved < positions.len() || compatible_roles < 7 || distinct_roles.len() < 3 { + return None; + } + let status = if duties_resolved == positions.len() && distinct_duties.len() >= 2 { + "role-duty-packet-validated" + } else if duties_resolved == 0 { + "role-packet-validated-duty-pending" + } else { + "role-packet-validated-partial-duty" + }; + Some(TacticRolePacket { + base_address, + offset: start as u64, + stride, + width, + status, + slots, + roles_resolved, + duties_resolved, + compatible_roles, }) } +#[cfg(target_os = "windows")] +fn read_packet_value(bytes: &[u8], offset: usize, width: u64) -> Option { + match width { + 4 => bytes + .get(offset..offset + 4) + .and_then(|slice| slice.try_into().ok()) + .map(u32::from_le_bytes) + .map(u64::from), + 8 => bytes + .get(offset..offset + 8) + .and_then(|slice| slice.try_into().ok()) + .map(u64::from_le_bytes), + _ => None, + } +} + +#[cfg(target_os = "windows")] +fn find_live_tactic_selection( + reader: &mut ProcessReader, + tactic_manager_pointer: u64, + managed_records: &[IndexedPlayerRecord], + slot_count: usize, +) -> Option { + let managed_people = managed_records + .iter() + .map(|record| record.person_address) + .collect::>(); + let mut candidates = Vec::new(); + candidates.push(tactic_manager_pointer); + for pointer_offset in TACTIC_SELECTION_POINTER_OFFSETS { + if let Some(pointer) = reader + .read_pointer(tactic_manager_pointer + pointer_offset) + .filter(|pointer| plausible_process_pointer(*pointer)) + { + candidates.push(pointer); + } + } + if let Some(bytes) = reader.read_bytes(tactic_manager_pointer, 0x6000) { + for offset in (0..bytes.len().saturating_sub(8)).step_by(8) { + let pointer = u64::from_le_bytes(bytes[offset..offset + 8].try_into().ok()?); + if plausible_process_pointer(pointer) { + candidates.push(pointer); + } + } + } + candidates.sort_unstable(); + candidates.dedup(); + + let mut best: Option = None; + for base_address in candidates { + let Some(bytes) = read_bounded_window(reader, base_address) else { + continue; + }; + for stride in [8_u64, 0x48] { + let needed = (slot_count.saturating_sub(1) as u64) + .saturating_mul(stride) + .saturating_add(8) as usize; + if bytes.len() < needed { + continue; + } + let max_start = bytes.len().saturating_sub(needed); + for start in (0..=max_start).step_by(8) { + let mut pointers = Vec::with_capacity(slot_count); + let mut resolved = 0_usize; + let mut plausible = 0_usize; + for index in 0..slot_count { + let offset = start + (index as u64 * stride) as usize; + let pointer = u64::from_le_bytes(bytes[offset..offset + 8].try_into().ok()?); + if plausible_process_pointer(pointer) { + plausible += 1; + } + if managed_people.contains(&pointer) { + resolved += 1; + } + pointers.push(pointer); + } + if resolved < 8 || plausible < 10 { + continue; + } + let replace = best + .as_ref() + .is_none_or(|current| resolved > current.resolved_players); + if replace { + best = Some(TacticSelection { + base_address, + offset: start as u64, + stride, + person_pointers: pointers, + resolved_players: resolved, + }); + if resolved == slot_count { + return best; + } + } + } + } + } + best +} + +#[cfg(target_os = "windows")] +fn read_bounded_window(reader: &mut ProcessReader, base_address: u64) -> Option> { + for size in [0x4000_usize, 0x2000, 0x1000] { + if let Some(bytes) = reader.read_bytes(base_address, size) { + return Some(bytes); + } + } + None +} + +#[cfg(target_os = "windows")] +fn plausible_process_pointer(pointer: u64) -> bool { + (0x10000000000..0x0000_8000_0000_0000).contains(&pointer) +} + #[cfg(target_os = "windows")] struct IndexSummary { total: u32, background: u32, + tactic_manager_pointer: Option, } #[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)) @@ -936,6 +1594,7 @@ fn index_full_player_database( process_id: u32, save_pointer: u64, player_vtable: u64, + module: ModuleInfo, managed_player_ids: &HashSet, managed_records: Vec, ) -> Result { @@ -943,7 +1602,15 @@ fn index_full_player_database( .into_iter() .map(|record| (record.id.clone(), record)) .collect(); - let candidate_addresses = reader.scan_private_memory_for_pointer(player_vtable)?; + let tactics_manager_vtable = module.base + profile.constants.tactics_manager_vtable_rva; + let mut object_hits = + scan_private_memory_for_pointers(reader, &[player_vtable, tactics_manager_vtable]) + .map_err(|error| ExtractionFailure::new("player_database", error.to_string()))?; + let candidate_addresses = object_hits.remove(&player_vtable).unwrap_or_default(); + let tactic_manager_hits = object_hits + .remove(&tactics_manager_vtable) + .unwrap_or_default(); + let tactic_manager_pointer = (tactic_manager_hits.len() == 1).then_some(tactic_manager_hits[0]); for address in candidate_addresses { if records.len() >= 250_000 { @@ -980,7 +1647,11 @@ fn index_full_player_database( records, }; } - Ok(IndexSummary { total, background }) + Ok(IndexSummary { + total, + background, + tactic_manager_pointer, + }) } #[cfg(target_os = "windows")] @@ -1035,12 +1706,193 @@ fn read_indexed_player_candidate( positions, managed_squad: false, visibility_safe: false, + raw_player_address: raw_player, + person_address: person, + contract_address: reader + .read_pointer(person.checked_add(profile.constants.person_contract_offset)?) + .filter(|value| *value != 0), }) } -fn display_name( - first_name: Option, - second_name: Option, +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, common_name: Option, ) -> Option { if let Some(common) = common_name.filter(|value| !value.trim().is_empty()) { @@ -1068,71 +1920,15 @@ fn read_name_field(reader: &mut ProcessReader, field: u64) -> Option { reader.read_length_prefixed_string(text) } -#[cfg(target_os = "windows")] -fn validate_vtable( - reader: &mut ProcessReader, - object: u64, - expected: u64, - stage: &'static str, -) -> Result<(), ExtractionFailure> { - let actual = reader.read_pointer(object).ok_or_else(|| { - ExtractionFailure::new(stage, "A required FM26 object could not be read.") - })?; - if actual != expected { - return Err(ExtractionFailure::new( - stage, - format!( - "A required FM26 object failed type validation (expected {}, found {}). No data was shown.", - hex_address(expected), - hex_address(actual) - ), - )); - } - Ok(()) -} - -fn parse_pattern(pattern: &str) -> Result>, ExtractionFailure> { - pattern - .split_whitespace() - .map(|token| { - if token == "??" || token == "?" { - Ok(None) - } else { - u8::from_str_radix(token, 16).map(Some).map_err(|_| { - ExtractionFailure::new( - "entity_map", - "The embedded signature pattern is invalid.", - ) - }) - } - }) - .collect() +#[cfg(test)] +fn checked_currency_transform(raw: i64, scale: u64, maximum: u64) -> Option { + let value = u64::try_from(raw).ok()?.checked_mul(scale)?; + (value <= maximum).then_some(value) } -fn find_entity_map(identity: &ExecutableIdentity) -> Option<&'static EntityMapProfile> { - static INDEX: OnceLock = OnceLock::new(); - let index = INDEX.get_or_init(|| { - serde_json::from_str(include_str!("../entity-maps/index.json")) - .expect("embedded entity-map index must be valid JSON") - }); - if index.schema_version != 1 { - return None; - } - index.profiles.iter().find(|profile| { - let declared_shape_is_valid = !profile.module.is_empty() - && profile - .signatures - .iter() - .all(|signature| !signature.name.is_empty() && !signature.pattern.is_empty()) - && profile.pointer_chains.iter().all(|chain| { - !chain.name.is_empty() && !chain.root.is_empty() && !chain.offsets.is_empty() - }); - declared_shape_is_valid - && identity.file_version.as_deref() == Some(profile.file_version.as_str()) - && identity.product_version.as_deref() == Some(profile.product_version.as_str()) - && identity.sha256.as_deref() == Some(profile.executable_sha256.as_str()) - && identity.architecture.as_deref() == Some(profile.architecture.as_str()) - }) +#[cfg(test)] +fn validated_enum<'a>(raw: usize, values: &'a [&'a str]) -> Option<&'a str> { + values.get(raw).copied() } fn read_executable_identity(path: &str) -> ExecutableIdentity { @@ -1215,382 +2011,13 @@ fn unix_milliseconds() -> String { .unwrap_or_default() } -#[cfg(target_os = "windows")] -#[derive(Clone, Copy)] -struct ModuleInfo { - base: u64, - size: usize, -} - -#[cfg(target_os = "windows")] -#[derive(Clone, Copy)] -struct MemoryRegion { - base: u64, - size: usize, -} - -#[cfg(target_os = "windows")] -struct ProcessReader { - handle: Handle, - bytes_read: usize, - last_error: Option, -} - -#[cfg(target_os = "windows")] -impl ProcessReader { - fn open(process_id: u32) -> Result { - let handle = unsafe { OpenProcess(READ_ONLY_PROCESS_ACCESS, 0, process_id) }; - if handle.is_null() { - return Err(unsafe { GetLastError() }); - } - Ok(Self { - handle, - bytes_read: 0, - last_error: None, - }) - } - - fn process_path(&mut self) -> Option { - let mut buffer = vec![0_u16; 32_768]; - let mut size = buffer.len() as u32; - if unsafe { QueryFullProcessImageNameW(self.handle, 0, buffer.as_mut_ptr(), &mut size) } - == 0 - { - self.last_error = Some(unsafe { GetLastError() }); - return None; - } - Some(String::from_utf16_lossy(&buffer[..size as usize])) - } - - fn module(&mut self, wanted_name: &str) -> Option { - let mut modules = vec![std::ptr::null_mut(); 2048]; - let mut needed = 0_u32; - if unsafe { - EnumProcessModulesEx( - self.handle, - modules.as_mut_ptr(), - (modules.len() * std::mem::size_of::()) as u32, - &mut needed, - 0x03, - ) - } == 0 - { - self.last_error = Some(unsafe { GetLastError() }); - return None; - } - let count = (needed as usize / std::mem::size_of::()).min(modules.len()); - for module in modules.into_iter().take(count) { - let mut name = [0_u16; 1024]; - let name_length = unsafe { - GetModuleBaseNameW(self.handle, module, name.as_mut_ptr(), name.len() as u32) - }; - if name_length == 0 { - continue; - } - if String::from_utf16_lossy(&name[..name_length as usize]) - .eq_ignore_ascii_case(wanted_name) - { - let mut info = NativeModuleInfo::default(); - if unsafe { - GetModuleInformation( - self.handle, - module, - &mut info, - std::mem::size_of::() as u32, - ) - } == 0 - { - self.last_error = Some(unsafe { GetLastError() }); - return None; - } - return Some(ModuleInfo { - base: info.base_of_dll as u64, - size: info.size_of_image as usize, - }); - } - } - None - } - - fn read_bytes(&mut self, address: u64, size: usize) -> Option> { - let mut buffer = vec![0_u8; size]; - let mut bytes_read = 0_usize; - let result = unsafe { - ReadProcessMemory( - self.handle, - address as *const std::ffi::c_void, - buffer.as_mut_ptr().cast(), - size, - &mut bytes_read, - ) - }; - self.bytes_read = self.bytes_read.saturating_add(bytes_read); - if result == 0 || bytes_read != size { - self.last_error = Some(unsafe { GetLastError() }); - return None; - } - Some(buffer) - } - - fn read_pointer(&mut self, address: u64) -> Option { - self.read_bytes(address, 8) - .map(|bytes| u64::from_le_bytes(bytes.try_into().expect("eight bytes"))) - } - - fn read_u32(&mut self, address: u64) -> Option { - self.read_bytes(address, 4) - .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes"))) - } - - fn read_i32(&mut self, address: u64) -> Option { - self.read_bytes(address, 4) - .map(|bytes| i32::from_le_bytes(bytes.try_into().expect("four bytes"))) - } - - fn read_length_prefixed_string(&mut self, address: u64) -> Option { - let length = self.read_u32(address)? as usize; - if length == 0 || length > MAX_STRING_BYTES { - return None; - } - let bytes = self.read_bytes(address + 4, length)?; - let value = String::from_utf8(bytes).ok()?; - if value.chars().any(char::is_control) { - return None; - } - Some(value) - } - - fn scan_module( - &mut self, - module: ModuleInfo, - pattern: &[Option], - ) -> Result, ExtractionFailure> { - if pattern.is_empty() || module.size < pattern.len() { - return Err(ExtractionFailure::new( - "manager_signature", - "The manager signature is empty or larger than the game module.", - )); - } - const CHUNK_SIZE: usize = 4 * 1024 * 1024; - let overlap = pattern.len().saturating_sub(1); - let mut hits = Vec::new(); - let mut offset = 0_usize; - while offset < module.size { - let read_start = offset.saturating_sub(if offset == 0 { 0 } else { overlap }); - let read_size = CHUNK_SIZE - .saturating_add(if offset == 0 { 0 } else { overlap }) - .min(module.size - read_start); - let bytes = self - .read_bytes(module.base + read_start as u64, read_size) - .ok_or_else(|| { - ExtractionFailure::new( - "manager_signature", - "The FM26 game module could not be scanned with read-only access.", - ) - })?; - for position in 0..=bytes.len().saturating_sub(pattern.len()) { - let absolute_offset = read_start + position; - if offset != 0 && absolute_offset < offset { - continue; - } - if pattern.iter().enumerate().all(|(index, expected)| { - expected.is_none_or(|value| bytes[position + index] == value) - }) { - hits.push(module.base + absolute_offset as u64); - } - } - offset = offset.saturating_add(CHUNK_SIZE); - } - Ok(hits) - } - - fn scan_private_memory_for_pointer( - &mut self, - pointer: u64, - ) -> Result, ExtractionFailure> { - const MEM_COMMIT: u32 = 0x1000; - const MEM_PRIVATE: u32 = 0x20000; - const PAGE_NOACCESS: u32 = 0x01; - const PAGE_GUARD: u32 = 0x100; - const CHUNK_SIZE: usize = 8 * 1024 * 1024; - const MAX_SCAN_BYTES: usize = 8 * 1024 * 1024 * 1024; - - let mut regions = Vec::new(); - let mut address = 0_u64; - let mut considered = 0_usize; - loop { - let mut info = NativeMemoryInfo::default(); - let queried = unsafe { - VirtualQueryEx( - self.handle, - address as *const std::ffi::c_void, - &mut info, - std::mem::size_of::(), - ) - }; - if queried == 0 { - break; - } - let base = info.base_address as u64; - let size = info.region_size; - if size == 0 { - break; - } - let readable = info.state == MEM_COMMIT - && info.memory_type == MEM_PRIVATE - && info.protect & (PAGE_NOACCESS | PAGE_GUARD) == 0; - if readable && considered.saturating_add(size) <= MAX_SCAN_BYTES { - regions.push(MemoryRegion { base, size }); - considered = considered.saturating_add(size); - } - let next = base.saturating_add(size as u64); - if next <= address { - break; - } - address = next; - } - if regions.is_empty() { - return Err(ExtractionFailure::new( - "player_database", - "No readable FM26 private-memory regions were available for player indexing.", - )); - } - - let needle = pointer.to_le_bytes(); - let mut hits = Vec::new(); - for region in regions { - let mut offset = 0_usize; - while offset < region.size { - let size = CHUNK_SIZE.min(region.size - offset); - let Some(bytes) = self.read_bytes(region.base + offset as u64, size) else { - offset = offset.saturating_add(size); - continue; - }; - let base = region.base + offset as u64; - let first_aligned = ((8 - (base as usize & 7)) & 7).min(bytes.len()); - let mut position = first_aligned; - while position + needle.len() <= bytes.len() { - if bytes[position..position + needle.len()] == needle { - hits.push(base + position as u64); - } - position += 8; - } - offset = offset.saturating_add(size); - } - } - Ok(hits) - } -} - -#[cfg(target_os = "windows")] -impl Drop for ProcessReader { - fn drop(&mut self) { - unsafe { - CloseHandle(self.handle); - } - } -} - -#[cfg(target_os = "windows")] -type Handle = *mut std::ffi::c_void; - -#[cfg(target_os = "windows")] -#[repr(C)] -#[derive(Default)] -struct NativeModuleInfo { - base_of_dll: *mut std::ffi::c_void, - size_of_image: u32, - entry_point: *mut std::ffi::c_void, -} - -#[cfg(target_os = "windows")] -#[repr(C)] -#[derive(Default)] -struct NativeMemoryInfo { - base_address: *mut std::ffi::c_void, - allocation_base: *mut std::ffi::c_void, - allocation_protect: u32, - partition_id: u16, - alignment: u16, - region_size: usize, - state: u32, - protect: u32, - memory_type: u32, - alignment_two: u32, -} - -#[cfg(target_os = "windows")] -#[link(name = "kernel32")] -unsafe extern "system" { - fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> Handle; - fn CloseHandle(handle: Handle) -> i32; - fn GetLastError() -> u32; - fn QueryFullProcessImageNameW( - process: Handle, - flags: u32, - file_name: *mut u16, - size: *mut u32, - ) -> i32; - fn ReadProcessMemory( - process: Handle, - base_address: *const std::ffi::c_void, - buffer: *mut std::ffi::c_void, - size: usize, - bytes_read: *mut usize, - ) -> i32; - fn VirtualQueryEx( - process: Handle, - address: *const std::ffi::c_void, - buffer: *mut NativeMemoryInfo, - length: usize, - ) -> usize; -} - -#[cfg(target_os = "windows")] -#[link(name = "psapi")] -unsafe extern "system" { - fn EnumProcessModulesEx( - process: Handle, - modules: *mut Handle, - size: u32, - needed: *mut u32, - filter: u32, - ) -> i32; - fn GetModuleBaseNameW(process: Handle, module: Handle, base_name: *mut u16, size: u32) -> u32; - fn GetModuleInformation( - process: Handle, - module: Handle, - module_info: *mut NativeModuleInfo, - size: u32, - ) -> i32; -} - -#[cfg(target_os = "windows")] -fn find_fm26_process() -> Option<(u32, Option)> { - let output = std::process::Command::new("tasklist") - .args(["/FI", "IMAGENAME eq fm.exe", "/FO", "CSV", "/NH"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&output.stdout); - let row = stdout - .lines() - .find(|line| line.to_ascii_lowercase().contains("fm.exe"))?; - let columns: Vec<&str> = row.trim().trim_matches('"').split("\",\"").collect(); - let pid = columns.get(1)?.replace(',', "").parse::().ok()?; - Some((pid, None)) -} - -#[cfg(not(target_os = "windows"))] -fn find_fm26_process() -> Option<(u32, Option)> { - None -} - #[cfg(test)] mod tests { use super::*; + use crate::fm26::{ + offsets::{embedded_entity_map_index, field_is_publishable, FieldDefinition}, + permissions::READ_ONLY_PROCESS_ACCESS, + }; #[test] fn connector_contract_never_requests_or_advertises_write_access() { @@ -1600,15 +2027,24 @@ mod tests { #[test] fn exact_build_profile_is_embedded_and_read_only() { - let index: EntityMapIndex = - serde_json::from_str(include_str!("../entity-maps/index.json")).unwrap(); - assert_eq!(index.schema_version, 1); + let index = embedded_entity_map_index(); + assert_eq!(index.schema_version, 2); assert_eq!(index.profiles.len(), 1); assert_eq!(index.profiles[0].module, "game_plugin.dll"); assert_eq!( index.profiles[0].executable_sha256, "3653C97F9CCEC2BE28EDC4FAAE67304B5B6C26733F2F07DEA3E7C591D3B9FF73" ); + let coverage = mapping_coverage(&index.profiles[0]); + assert!(coverage + .iter() + .any(|section| section.section == "player" && section.validated >= 7)); + assert!(coverage + .iter() + .any(|section| section.section == "contract" && section.unmapped >= 4)); + assert!(coverage + .iter() + .any(|section| section.section == "recruitment" && section.candidate >= 2)); } #[test] @@ -1624,6 +2060,7 @@ mod tests { #[cfg(target_os = "windows")] #[test] + #[ignore = "live FM26 integration test; run manually with an active save"] fn running_exact_build_extracts_live_entities_without_hidden_ability_fields() { if find_fm26_process().is_none() { return; @@ -1634,30 +2071,50 @@ mod tests { "{}", snapshot.status.message ); - assert_eq!(snapshot.manager_name.as_deref(), Some("Tobias Thorsen")); - assert_eq!( - snapshot - .clubs - .first() - .and_then(|club| club["name"].as_str()), - Some("Madla IL") + assert!(snapshot + .manager_name + .as_deref() + .is_some_and(|name| !name.is_empty())); + assert!(snapshot + .clubs + .first() + .and_then(|club| club["name"].as_str()) + .is_some_and(|name| !name.is_empty())); + assert!( + snapshot.players.len() >= 11, + "expected at least a selected squad, got {}", + snapshot.players.len() ); - assert_eq!(snapshot.players.len(), 38); assert!(snapshot.tactic.is_none()); assert_eq!(snapshot.tactic_source, "none"); - assert_eq!(snapshot.status.live_memory_tactic_read, "disabled"); + assert_eq!(snapshot.status.live_memory_tactic_read, "object_not_found"); for player in &snapshot.players { assert!(player["currentAbility"].is_null()); 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"); + } + if let Some(lars) = snapshot + .players + .iter() + .find(|player| player["id"] == "53179170") + { + 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")] #[test] - fn full_save_index_keeps_unvalidated_background_players_hidden() { + #[ignore = "live FM26 integration test; run manually with an active save"] + fn full_save_index_exposes_identity_only_background_search_records() { if find_fm26_process().is_none() { return; } @@ -1669,11 +2126,16 @@ 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.live_memory_tactic_read, "ready"); + assert!(snapshot.status.tactic_manager_pointer.is_some()); + let tactic = snapshot.tactic.as_ref().expect("live tactic"); + assert!(tactic["formation"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert_eq!(tactic["slots"].as_array().map(Vec::len), Some(11)); + assert_eq!(snapshot.tactic_source, "live-memory"); assert_eq!( snapshot.status.visible_players_loaded, snapshot.players.len() as u32 @@ -1681,6 +2143,456 @@ mod tests { let visible_results = search_indexed_players("Jøran".to_string()); assert!(visible_results .iter() - .all(|player| player["visibility"] == "club-visible")); + .all(|player| player["visibility"] == "known" || player["visibility"] == "unknown")); + } + + #[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)); + } + } + + #[test] + fn currency_and_enum_transforms_reject_invalid_values() { + assert_eq!( + checked_currency_transform(125_000, 1, 1_000_000_000), + Some(125_000) + ); + assert_eq!(checked_currency_transform(-1, 1, 1_000_000_000), None); + assert_eq!( + checked_currency_transform(2_000_000, 1_000, 1_000_000_000), + None + ); + assert_eq!( + validated_enum(1, &["unknown", "interested", "not_interested"]), + Some("interested") + ); + assert_eq!(validated_enum(4, &["unknown", "interested"]), None); + } + + #[test] + fn candidates_cannot_cross_the_publication_confidence_gate() { + let candidate = FieldDefinition { + offset: Some(32), + source: "contract".to_string(), + value_type: "currency".to_string(), + transform: Some("identity".to_string()), + status: "candidate".to_string(), + confidence: 0.94, + validations: vec!["one observation".to_string()], + }; + assert!(!field_is_publishable(&candidate)); + let validated = FieldDefinition { + status: "validated".to_string(), + confidence: 0.97, + validations: vec!["100 players".to_string(), "two saves".to_string()], + ..candidate + }; + assert!(field_is_publishable(&validated)); + } + + #[cfg(target_os = "windows")] + #[test] + #[ignore] + fn debug_live_tactic_contract_windows() { + if find_fm26_process().is_none() { + println!("FM26 is not running."); + return; + } + let snapshot = collect_snapshot(true, None); + println!( + "snapshot state={} message={}", + snapshot.status.state, snapshot.status.message + ); + assert_eq!(snapshot.status.state, "connected"); + + let index = PLAYER_DATABASE_INDEX + .get() + .expect("player index") + .read() + .expect("player index lock"); + let process_id = index.process_id; + let mut managed = index + .records + .values() + .filter(|record| record.managed_squad) + .cloned() + .collect::>(); + managed.sort_by(|left, right| left.name.cmp(&right.name)); + drop(index); + let active_tactic_name_needles = [ + "Sveingard", + "Jelsa", + "Sallabegolli", + "Osland", + "Romvig", + "Rabenorolahy", + "Sandtorv", + "Bergset", + "Helgevold", + "Thulin", + "Marchewka", + ]; + let active_tactic_records = managed + .iter() + .filter(|record| { + active_tactic_name_needles + .iter() + .any(|needle| record.name.contains(needle)) + }) + .cloned() + .collect::>(); + + let mut reader = ProcessReader::open(process_id).expect("open FM26"); + let process_path = reader.process_path().expect("process path"); + let identity = read_executable_identity(&process_path); + let profile = find_entity_map( + identity.file_version.as_deref(), + identity.product_version.as_deref(), + identity.sha256.as_deref(), + identity.architecture.as_deref(), + ) + .expect("entity map"); + let module = reader.module(&profile.module).expect("game module"); + let player_vtable = reader + .read_pointer(managed[0].raw_player_address) + .expect("player vtable"); + let tactic_vtable = module.base + profile.constants.tactics_manager_vtable_rva; + let mut scan_needles = vec![player_vtable, tactic_vtable]; + for record in &active_tactic_records { + scan_needles.push(record.raw_player_address); + scan_needles.push(record.person_address); + if let Some(contract_address) = record.contract_address { + scan_needles.push(contract_address); + } + } + let mut hits = scan_private_memory_for_pointers(&mut reader, &scan_needles) + .expect("scan private memory"); + let player_hits = hits.remove(&player_vtable).unwrap_or_default(); + let tactic_hits = hits.remove(&tactic_vtable).unwrap_or_default(); + println!( + "player_vtable={} hits={} tactic_vtable={} hits={:?}", + hex_address(player_vtable), + player_hits.len(), + hex_address(tactic_vtable), + tactic_hits + .iter() + .take(8) + .map(|value| hex_address(*value)) + .collect::>() + ); + println!("active tactic player pointer hits:"); + let mut clustered_hits = Vec::new(); + for record in &active_tactic_records { + for (kind, pointer) in [ + ("raw", Some(record.raw_player_address)), + ("person", Some(record.person_address)), + ("contract", record.contract_address), + ] { + let Some(pointer) = pointer else { + continue; + }; + let found = hits.remove(&pointer).unwrap_or_default(); + println!( + " {} {} {kind}={} hits={}", + record.id, + record.name, + hex_address(pointer), + found.len() + ); + for address in found.into_iter().take(64) { + clustered_hits.push((address, format!("{kind}:{} {}", record.id, record.name))); + } + } + } + clustered_hits.sort_by_key(|(address, _)| *address); + let mut page_counts: HashMap> = HashMap::new(); + for (address, label) in clustered_hits { + page_counts + .entry(address & !0xfff) + .or_default() + .push(format!("{}@{}", label, hex_address(address))); + } + let mut clusters = page_counts.into_iter().collect::>(); + clusters.sort_by(|left, right| right.1.len().cmp(&left.1.len())); + println!("active tactic pointer clusters:"); + for (page, labels) in clusters + .into_iter() + .take(30) + .filter(|(_, labels)| labels.len() >= 2) + { + println!( + " page={} count={} {:?}", + hex_address(page), + labels.len(), + labels + ); + } + + if let Some(tactic_object) = tactic_hits.first().copied() { + let bytes = reader + .read_bytes(tactic_object, 8192) + .expect("tactic manager window"); + println!("tactic_object={}", hex_address(tactic_object)); + println!("small tactic i32/u32 values:"); + for offset in (0..bytes.len()).step_by(4) { + let signed = i32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let unsigned = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + if (-1..=100).contains(&signed) { + println!(" off={offset:04X} i32={signed} u32={unsigned}"); + } + } + println!("tactic pointer-like fields:"); + for offset in (0..2048).step_by(8) { + let pointer = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + if (0x10000000000..0x0000_8000_0000_0000).contains(&pointer) { + let first_u32 = reader.read_u32(pointer).unwrap_or_default(); + let first_ptr = reader.read_pointer(pointer).unwrap_or_default(); + println!( + " off={offset:04X} ptr={} first_u32={} first_ptr={}", + hex_address(pointer), + first_u32, + hex_address(first_ptr) + ); + } + } + + let large_bytes = reader + .read_bytes(tactic_object, 0x8000) + .expect("large tactic manager window"); + println!("managed players for tactic matching:"); + for record in &managed { + println!( + " {} {} raw={} person={} contract={}", + record.id, + record.name, + hex_address(record.raw_player_address), + hex_address(record.person_address), + record + .contract_address + .map(hex_address) + .unwrap_or_else(|| "none".to_string()) + ); + } + print_player_reference_hits("tactic-manager", tactic_object, &large_bytes, &managed); + print_role_like_runs("tactic-manager", tactic_object, &large_bytes); + + let mut child_targets = Vec::new(); + for offset in (0..0x1000).step_by(8) { + let pointer = + u64::from_le_bytes(large_bytes[offset..offset + 8].try_into().unwrap()); + if (0x10000000000..0x0000_8000_0000_0000).contains(&pointer) + && !child_targets.contains(&pointer) + { + child_targets.push(pointer); + } + } + println!("tactic child pointer probes:"); + for (index, target) in child_targets.into_iter().take(96).enumerate() { + let Some(child_bytes) = reader.read_bytes(target, 0x1000) else { + continue; + }; + let label = format!("child#{index:02}"); + let player_hits = count_player_reference_hits(target, &child_bytes, &managed); + let role_hits = count_role_like_runs(&child_bytes); + if player_hits > 0 || role_hits > 0 { + println!( + " {label} base={} player_hits={} role_runs={}", + hex_address(target), + player_hits, + role_hits + ); + print_player_reference_hits(&label, target, &child_bytes, &managed); + print_role_like_runs(&label, target, &child_bytes); + } + } + } + + println!("managed contract windows:"); + for record in managed.iter().take(12) { + let Some(contract) = record.contract_address else { + println!("{} {} no contract", record.id, record.name); + continue; + }; + let bytes = reader.read_bytes(contract, 1024).expect("contract window"); + let mut small_values = Vec::new(); + let mut date_candidates = Vec::new(); + for offset in (0..bytes.len()).step_by(4) { + let signed = i32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let unsigned = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + if (0..=5_000_000).contains(&signed) { + small_values.push(format!("{offset:03X}:{signed}")); + } + let day = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()); + let year = u16::from_le_bytes(bytes[offset + 2..offset + 4].try_into().unwrap()); + if (1..=366).contains(&day) && (2026..=2045).contains(&year) { + date_candidates.push(format!("{offset:03X}:{year}-{day} raw={unsigned}")); + } + } + println!( + "{} {} contract={} vtable={} dates=[{}] values=[{}]", + record.id, + record.name, + hex_address(contract), + hex_address(reader.read_pointer(contract).unwrap_or_default()), + date_candidates.join(", "), + small_values.join(" ") + ); + } + } + + #[cfg(target_os = "windows")] + fn print_player_reference_hits( + label: &str, + base: u64, + bytes: &[u8], + records: &[IndexedPlayerRecord], + ) { + for offset in (0..bytes.len().saturating_sub(8)).step_by(8) { + let pointer = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + for record in records { + if pointer == record.raw_player_address { + println!( + " {label}+{offset:04X}: raw_player -> {} {}", + record.id, record.name + ); + } + if pointer == record.person_address { + println!( + " {label}+{offset:04X}: person -> {} {}", + record.id, record.name + ); + } + if Some(pointer) == record.contract_address { + println!( + " {label}+{offset:04X}: contract -> {} {}", + record.id, record.name + ); + } + } + } + for offset in (0..bytes.len().saturating_sub(4)).step_by(4) { + let uid = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + for record in records { + if record.id.parse::().ok() == Some(uid) { + println!( + " {label}+{offset:04X}: uid -> {} {}", + record.id, record.name + ); + } + } + } + let _ = base; + } + + #[cfg(target_os = "windows")] + fn count_player_reference_hits( + _base: u64, + bytes: &[u8], + records: &[IndexedPlayerRecord], + ) -> usize { + let mut hits = 0; + for offset in (0..bytes.len().saturating_sub(8)).step_by(8) { + let pointer = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + hits += records + .iter() + .filter(|record| { + pointer == record.raw_player_address + || pointer == record.person_address + || Some(pointer) == record.contract_address + }) + .count(); + } + for offset in (0..bytes.len().saturating_sub(4)).step_by(4) { + let uid = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + hits += records + .iter() + .filter(|record| record.id.parse::().ok() == Some(uid)) + .count(); + } + hits + } + + #[cfg(target_os = "windows")] + fn print_role_like_runs(label: &str, base: u64, bytes: &[u8]) { + let expected_roles = [1_u32, 3, 7, 8, 10, 14, 22, 25, 26, 38]; + for start in (0..bytes.len().saturating_sub(64)).step_by(4) { + let mut values = Vec::new(); + for offset in (start..start + 64).step_by(4) { + let value = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + values.push(value); + } + let bounded = values + .iter() + .filter(|value| **value <= 45 || **value == u32::MAX) + .count(); + let role_matches = values + .iter() + .filter(|value| expected_roles.contains(value)) + .count(); + let non_zero = values.iter().filter(|value| **value != 0).count(); + if bounded >= 12 && role_matches >= 3 && non_zero >= 5 { + let absolute = base + start as u64; + println!( + " {label}+{start:04X} abs={} values={:?}", + hex_address(absolute), + values + ); + } + } + } + + #[cfg(target_os = "windows")] + fn count_role_like_runs(bytes: &[u8]) -> usize { + let expected_roles = [1_u32, 3, 7, 8, 10, 14, 22, 25, 26, 38]; + let mut runs = 0; + for start in (0..bytes.len().saturating_sub(64)).step_by(4) { + let mut values = Vec::new(); + for offset in (start..start + 64).step_by(4) { + values.push(u32::from_le_bytes( + bytes[offset..offset + 4].try_into().unwrap(), + )); + } + let bounded = values + .iter() + .filter(|value| **value <= 45 || **value == u32::MAX) + .count(); + let role_matches = values + .iter() + .filter(|value| expected_roles.contains(value)) + .count(); + let non_zero = values.iter().filter(|value| **value != 0).count(); + if bounded >= 12 && role_matches >= 3 && non_zero >= 5 { + runs += 1; + } + } + runs } } diff --git a/src-tauri/src/data/clubs.rs b/src-tauri/src/data/clubs.rs new file mode 100644 index 0000000..bb0f5dd --- /dev/null +++ b/src-tauri/src/data/clubs.rs @@ -0,0 +1,7 @@ +#[derive(Clone, Debug)] +pub(crate) struct ClubRecord { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) nation: Option, + pub(crate) league: Option, +} diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs new file mode 100644 index 0000000..c8d5652 --- /dev/null +++ b/src-tauri/src/data/mod.rs @@ -0,0 +1,7 @@ +#![allow(dead_code)] + +pub(crate) mod clubs; +pub(crate) mod nations; +pub(crate) mod players; +pub(crate) mod scout_knowledge; +pub(crate) mod tactics; diff --git a/src-tauri/src/data/nations.rs b/src-tauri/src/data/nations.rs new file mode 100644 index 0000000..18c1816 --- /dev/null +++ b/src-tauri/src/data/nations.rs @@ -0,0 +1,5 @@ +#[derive(Clone, Debug)] +pub(crate) struct NationRecord { + pub(crate) id: String, + pub(crate) name: String, +} diff --git a/src-tauri/src/data/players.rs b/src-tauri/src/data/players.rs new file mode 100644 index 0000000..93bc391 --- /dev/null +++ b/src-tauri/src/data/players.rs @@ -0,0 +1,20 @@ +use std::collections::HashMap; + +#[derive(Clone)] +pub(crate) struct IndexedPlayerRecord { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) positions: Vec, + pub(crate) managed_squad: bool, + pub(crate) visibility_safe: bool, + pub(crate) raw_player_address: u64, + pub(crate) person_address: u64, + pub(crate) contract_address: Option, +} + +#[derive(Default)] +pub(crate) struct PlayerDatabaseIndex { + pub(crate) process_id: u32, + pub(crate) save_pointer: u64, + pub(crate) records: HashMap, +} diff --git a/src-tauri/src/data/scout_knowledge.rs b/src-tauri/src/data/scout_knowledge.rs new file mode 100644 index 0000000..a2069ad --- /dev/null +++ b/src-tauri/src/data/scout_knowledge.rs @@ -0,0 +1,14 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ScoutKnowledgeLevel { + FullyKnown, + PartiallyKnown, + Unknown, +} + +#[derive(Clone, Debug)] +pub(crate) struct ScoutKnowledgeRecord { + pub(crate) level: ScoutKnowledgeLevel, + pub(crate) confidence: u8, + pub(crate) last_scouted_date: Option, + pub(crate) report_reliability: Option, +} diff --git a/src-tauri/src/data/tactics.rs b/src-tauri/src/data/tactics.rs new file mode 100644 index 0000000..e0c1765 --- /dev/null +++ b/src-tauri/src/data/tactics.rs @@ -0,0 +1,14 @@ +#[derive(Clone, Debug)] +pub(crate) enum TacticSource { + None, + FmfFile, + LiveMemory, +} + +#[derive(Clone, Debug)] +pub(crate) struct TacticRecord { + pub(crate) source: TacticSource, + pub(crate) formation: Option, + pub(crate) roles: Vec, + pub(crate) duties: Vec, +} diff --git a/src-tauri/src/fm26/memory.rs b/src-tauri/src/fm26/memory.rs new file mode 100644 index 0000000..832234a --- /dev/null +++ b/src-tauri/src/fm26/memory.rs @@ -0,0 +1,276 @@ +#[cfg(target_os = "windows")] +use super::permissions::READ_ONLY_PROCESS_ACCESS; + +const MAX_STRING_BYTES: usize = 192; + +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +pub(crate) struct ModuleInfo { + pub(crate) base: u64, + pub(crate) size: usize, +} + +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +pub(crate) struct MemoryRegion { + pub(crate) base: u64, + pub(crate) size: usize, +} + +#[cfg(target_os = "windows")] +pub(crate) struct ProcessReader { + handle: Handle, + pub(crate) bytes_read: usize, + pub(crate) last_error: Option, +} + +#[cfg(target_os = "windows")] +impl ProcessReader { + pub(crate) fn open(process_id: u32) -> Result { + let handle = unsafe { OpenProcess(READ_ONLY_PROCESS_ACCESS, 0, process_id) }; + if handle.is_null() { + return Err(unsafe { GetLastError() }); + } + Ok(Self { + handle, + bytes_read: 0, + last_error: None, + }) + } + + pub(crate) fn process_path(&mut self) -> Option { + let mut buffer = vec![0_u16; 32_768]; + let mut size = buffer.len() as u32; + if unsafe { QueryFullProcessImageNameW(self.handle, 0, buffer.as_mut_ptr(), &mut size) } + == 0 + { + self.last_error = Some(unsafe { GetLastError() }); + return None; + } + Some(String::from_utf16_lossy(&buffer[..size as usize])) + } + + pub(crate) fn module(&mut self, wanted_name: &str) -> Option { + let mut modules = vec![std::ptr::null_mut(); 2048]; + let mut needed = 0_u32; + if unsafe { + EnumProcessModulesEx( + self.handle, + modules.as_mut_ptr(), + (modules.len() * std::mem::size_of::()) as u32, + &mut needed, + 0x03, + ) + } == 0 + { + self.last_error = Some(unsafe { GetLastError() }); + return None; + } + let count = (needed as usize / std::mem::size_of::()).min(modules.len()); + for module in modules.into_iter().take(count) { + let mut name = [0_u16; 1024]; + let name_length = unsafe { + GetModuleBaseNameW(self.handle, module, name.as_mut_ptr(), name.len() as u32) + }; + if name_length == 0 { + continue; + } + if String::from_utf16_lossy(&name[..name_length as usize]) + .eq_ignore_ascii_case(wanted_name) + { + let mut info = NativeModuleInfo::default(); + if unsafe { + GetModuleInformation( + self.handle, + module, + &mut info, + std::mem::size_of::() as u32, + ) + } == 0 + { + self.last_error = Some(unsafe { GetLastError() }); + return None; + } + return Some(ModuleInfo { + base: info.base_of_dll as u64, + size: info.size_of_image as usize, + }); + } + } + None + } + + pub(crate) fn read_bytes(&mut self, address: u64, size: usize) -> Option> { + let mut buffer = vec![0_u8; size]; + let mut bytes_read = 0_usize; + let result = unsafe { + ReadProcessMemory( + self.handle, + address as *const std::ffi::c_void, + buffer.as_mut_ptr().cast(), + size, + &mut bytes_read, + ) + }; + self.bytes_read = self.bytes_read.saturating_add(bytes_read); + if result == 0 || bytes_read != size { + self.last_error = Some(unsafe { GetLastError() }); + return None; + } + Some(buffer) + } + + pub(crate) fn read_pointer(&mut self, address: u64) -> Option { + self.read_bytes(address, 8) + .map(|bytes| u64::from_le_bytes(bytes.try_into().expect("eight bytes"))) + } + + pub(crate) fn read_u32(&mut self, address: u64) -> Option { + self.read_bytes(address, 4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes"))) + } + + pub(crate) fn read_i32(&mut self, address: u64) -> Option { + self.read_bytes(address, 4) + .map(|bytes| i32::from_le_bytes(bytes.try_into().expect("four bytes"))) + } + + pub(crate) fn read_length_prefixed_string(&mut self, address: u64) -> Option { + let length = self.read_u32(address)? as usize; + if length == 0 || length > MAX_STRING_BYTES { + return None; + } + let bytes = self.read_bytes(address + 4, length)?; + let value = String::from_utf8(bytes).ok()?; + if value.chars().any(char::is_control) { + return None; + } + Some(value) + } + + pub(crate) fn readable_private_regions(&mut self, max_scan_bytes: usize) -> Vec { + const MEM_COMMIT: u32 = 0x1000; + const MEM_PRIVATE: u32 = 0x20000; + const PAGE_NOACCESS: u32 = 0x01; + const PAGE_GUARD: u32 = 0x100; + + let mut regions = Vec::new(); + let mut address = 0_u64; + let mut considered = 0_usize; + loop { + let mut info = NativeMemoryInfo::default(); + let queried = unsafe { + VirtualQueryEx( + self.handle, + address as *const std::ffi::c_void, + &mut info, + std::mem::size_of::(), + ) + }; + if queried == 0 { + break; + } + let base = info.base_address as u64; + let size = info.region_size; + if size == 0 { + break; + } + let readable = info.state == MEM_COMMIT + && info.memory_type == MEM_PRIVATE + && info.protect & (PAGE_NOACCESS | PAGE_GUARD) == 0; + if readable && considered.saturating_add(size) <= max_scan_bytes { + regions.push(MemoryRegion { base, size }); + considered = considered.saturating_add(size); + } + let next = base.saturating_add(size as u64); + if next <= address { + break; + } + address = next; + } + regions + } +} + +#[cfg(target_os = "windows")] +impl Drop for ProcessReader { + fn drop(&mut self) { + unsafe { + CloseHandle(self.handle); + } + } +} + +#[cfg(target_os = "windows")] +type Handle = *mut std::ffi::c_void; + +#[cfg(target_os = "windows")] +#[repr(C)] +#[derive(Default)] +struct NativeModuleInfo { + base_of_dll: *mut std::ffi::c_void, + size_of_image: u32, + entry_point: *mut std::ffi::c_void, +} + +#[cfg(target_os = "windows")] +#[repr(C)] +#[derive(Default)] +struct NativeMemoryInfo { + base_address: *mut std::ffi::c_void, + allocation_base: *mut std::ffi::c_void, + allocation_protect: u32, + partition_id: u16, + alignment: u16, + region_size: usize, + state: u32, + protect: u32, + memory_type: u32, + alignment_two: u32, +} + +#[cfg(target_os = "windows")] +#[link(name = "kernel32")] +unsafe extern "system" { + fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn CloseHandle(handle: Handle) -> i32; + fn GetLastError() -> u32; + fn QueryFullProcessImageNameW( + process: Handle, + flags: u32, + file_name: *mut u16, + size: *mut u32, + ) -> i32; + fn ReadProcessMemory( + process: Handle, + base_address: *const std::ffi::c_void, + buffer: *mut std::ffi::c_void, + size: usize, + bytes_read: *mut usize, + ) -> i32; + fn VirtualQueryEx( + process: Handle, + address: *const std::ffi::c_void, + buffer: *mut NativeMemoryInfo, + length: usize, + ) -> usize; +} + +#[cfg(target_os = "windows")] +#[link(name = "psapi")] +unsafe extern "system" { + fn EnumProcessModulesEx( + process: Handle, + modules: *mut Handle, + size: u32, + needed: *mut u32, + filter: u32, + ) -> i32; + fn GetModuleBaseNameW(process: Handle, module: Handle, base_name: *mut u16, size: u32) -> u32; + fn GetModuleInformation( + process: Handle, + module: Handle, + module_info: *mut NativeModuleInfo, + size: u32, + ) -> i32; +} diff --git a/src-tauri/src/fm26/mod.rs b/src-tauri/src/fm26/mod.rs new file mode 100644 index 0000000..8b13bc5 --- /dev/null +++ b/src-tauri/src/fm26/mod.rs @@ -0,0 +1,10 @@ +pub(crate) mod memory; +pub(crate) mod offsets; +pub(crate) mod parser; +pub(crate) mod permissions; +pub(crate) mod process; +pub(crate) mod roles; +pub(crate) mod scanner; +pub(crate) mod structs; +pub(crate) mod tactics; +pub(crate) mod validator; diff --git a/src-tauri/src/fm26/offsets.rs b/src-tauri/src/fm26/offsets.rs new file mode 100644 index 0000000..2f41683 --- /dev/null +++ b/src-tauri/src/fm26/offsets.rs @@ -0,0 +1,178 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::OnceLock}; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MappingCoverage { + pub(crate) section: String, + pub(crate) validated: u32, + pub(crate) candidate: u32, + pub(crate) unmapped: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EntityMapIndex { + pub(crate) schema_version: u32, + pub(crate) profiles: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EntityMapProfile { + pub(crate) id: String, + pub(crate) build_fingerprint: BuildFingerprint, + pub(crate) file_version: String, + pub(crate) product_version: String, + pub(crate) executable_sha256: String, + pub(crate) architecture: String, + pub(crate) module: String, + pub(crate) signatures: Vec, + pub(crate) pointer_chains: Vec, + pub(crate) constants: MapConstants, + pub(crate) sections: HashMap>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BuildFingerprint { + pub(crate) executable_sha256: String, + pub(crate) file_version: String, + pub(crate) product_version: String, + pub(crate) architecture: String, + pub(crate) module: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FieldDefinition { + pub(crate) offset: Option, + pub(crate) source: String, + pub(crate) value_type: String, + pub(crate) transform: Option, + pub(crate) status: String, + pub(crate) confidence: f64, + pub(crate) validations: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct EntitySignature { + pub(crate) name: String, + pub(crate) pattern: String, +} + +#[derive(Deserialize)] +pub(crate) struct PointerChain { + pub(crate) name: String, + pub(crate) root: String, + pub(crate) offsets: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MapConstants { + pub(crate) manager_registry_vector_offset: u64, + pub(crate) human_person_offset: u64, + pub(crate) person_first_name_offset: u64, + pub(crate) person_second_name_offset: u64, + pub(crate) person_common_name_offset: u64, + pub(crate) person_contract_offset: u64, + pub(crate) contract_team_offset: u64, + pub(crate) team_vtable_rva: u64, + pub(crate) team_club_offset: u64, + pub(crate) team_players_start_offset: u64, + pub(crate) team_players_end_offset: u64, + pub(crate) club_vtable_rva: u64, + pub(crate) club_name_offset: u64, + pub(crate) entity_uid_offset: u64, + pub(crate) player_person_offset: u64, + pub(crate) player_positions_offset: u64, + pub(crate) player_attributes_offset: u64, + pub(crate) player_current_date_offset: u64, + pub(crate) person_nationality_offset: u64, + pub(crate) nation_name_offset: u64, + pub(crate) person_birth_date_offset: u64, + pub(crate) tactics_manager_vtable_rva: u64, +} + +pub(crate) fn embedded_entity_map_index() -> &'static EntityMapIndex { + static INDEX: OnceLock = OnceLock::new(); + INDEX.get_or_init(|| { + serde_json::from_str(include_str!("../../entity-maps/index.json")) + .expect("embedded entity-map index must be valid JSON") + }) +} + +pub(crate) fn find_entity_map( + file_version: Option<&str>, + product_version: Option<&str>, + executable_sha256: Option<&str>, + architecture: Option<&str>, +) -> Option<&'static EntityMapProfile> { + let index = embedded_entity_map_index(); + if index.schema_version != 2 { + return None; + } + index.profiles.iter().find(|profile| { + let declared_shape_is_valid = !profile.module.is_empty() + && profile.build_fingerprint.executable_sha256 == profile.executable_sha256 + && profile.build_fingerprint.file_version == profile.file_version + && profile.build_fingerprint.product_version == profile.product_version + && profile.build_fingerprint.architecture == profile.architecture + && profile.build_fingerprint.module == profile.module + && profile + .signatures + .iter() + .all(|signature| !signature.name.is_empty() && !signature.pattern.is_empty()) + && profile.pointer_chains.iter().all(|chain| { + !chain.name.is_empty() && !chain.root.is_empty() && !chain.offsets.is_empty() + }); + declared_shape_is_valid + && file_version == Some(profile.file_version.as_str()) + && product_version == Some(profile.product_version.as_str()) + && executable_sha256 == Some(profile.executable_sha256.as_str()) + && architecture == Some(profile.architecture.as_str()) + }) +} + +pub(crate) fn mapping_coverage(profile: &EntityMapProfile) -> Vec { + let mut coverage: Vec = profile + .sections + .iter() + .map(|(section, fields)| { + let mut item = MappingCoverage { + section: section.clone(), + validated: 0, + candidate: 0, + unmapped: 0, + }; + for field in fields.values() { + let structurally_valid = !field.source.is_empty() + && !field.value_type.is_empty() + && field.confidence.is_finite() + && (0.0..=1.0).contains(&field.confidence) + && (field.status != "validated" || field_is_publishable(field)); + let _transform_is_declared = field.transform.as_deref().unwrap_or("identity"); + if !structurally_valid { + item.unmapped += 1; + } else { + match field.status.as_str() { + "validated" => item.validated += 1, + "candidate" => item.candidate += 1, + _ => item.unmapped += 1, + } + } + } + item + }) + .collect(); + coverage.sort_by(|left, right| left.section.cmp(&right.section)); + coverage +} + +pub(crate) fn field_is_publishable(field: &FieldDefinition) -> bool { + field.status == "validated" + && field.offset.is_some() + && field.confidence >= 0.95 + && !field.validations.is_empty() +} diff --git a/src-tauri/src/fm26/parser.rs b/src-tauri/src/fm26/parser.rs new file mode 100644 index 0000000..ac16dca --- /dev/null +++ b/src-tauri/src/fm26/parser.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; + +use super::structs::{HIDDEN_ATTRIBUTE_INDEXES, PLAYER_ATTRIBUTE_NAMES}; + +pub(crate) fn display_attribute(raw: u8) -> u8 { + ((raw.saturating_add(4)) / 5).clamp(1, 20) +} + +pub(crate) 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() +} + +pub(crate) 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" + } +} diff --git a/src-tauri/src/fm26/permissions.rs b/src-tauri/src/fm26/permissions.rs new file mode 100644 index 0000000..107bbf2 --- /dev/null +++ b/src-tauri/src/fm26/permissions.rs @@ -0,0 +1,7 @@ +pub(crate) const READ_ONLY_PROCESS_ACCESS: u32 = 0x1410; +pub(crate) const READ_ONLY_PROCESS_ACCESS_LABEL: &str = + "PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ (0x1410)"; + +pub(crate) fn can_write_memory() -> bool { + false +} diff --git a/src-tauri/src/fm26/process.rs b/src-tauri/src/fm26/process.rs new file mode 100644 index 0000000..b0f6465 --- /dev/null +++ b/src-tauri/src/fm26/process.rs @@ -0,0 +1,22 @@ +#[cfg(target_os = "windows")] +pub(crate) fn find_fm26_process() -> Option<(u32, Option)> { + let output = std::process::Command::new("tasklist") + .args(["/FI", "IMAGENAME eq fm.exe", "/FO", "CSV", "/NH"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let row = stdout + .lines() + .find(|line| line.to_ascii_lowercase().contains("fm.exe"))?; + let columns: Vec<&str> = row.trim().trim_matches('"').split("\",\"").collect(); + let pid = columns.get(1)?.replace(',', "").parse::().ok()?; + Some((pid, None)) +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn find_fm26_process() -> Option<(u32, Option)> { + None +} diff --git a/src-tauri/src/fm26/roles.rs b/src-tauri/src/fm26/roles.rs new file mode 100644 index 0000000..c0a5bbe --- /dev/null +++ b/src-tauri/src/fm26/roles.rs @@ -0,0 +1,761 @@ +use std::collections::HashMap; + +use serde::Serialize; +use serde_json::json; + +use super::structs::POSITION_NAMES; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RoleDefinition { + pub(crate) key: &'static str, + pub(crate) label: &'static str, + pub(crate) short_label: &'static str, + pub(crate) mask: u64, + pub(crate) positions: &'static [&'static str], + primary_attributes: &'static [&'static str], + secondary_attributes: &'static [&'static str], +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct DutyDefinition { + pub(crate) key: &'static str, + pub(crate) label: &'static str, + pub(crate) short_label: &'static str, + pub(crate) mask: u64, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct OutOfPossessionRoleDefinition { + pub(crate) key: &'static str, + pub(crate) label: &'static str, + pub(crate) short_label: &'static str, + pub(crate) mask: u64, + pub(crate) positions: &'static [&'static str], + primary_attributes: &'static [&'static str], + secondary_attributes: &'static [&'static str], +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlayerRoleFit { + pub(crate) role_key: String, + pub(crate) role: String, + pub(crate) short_role: String, + pub(crate) role_id_mask: String, + pub(crate) positions: &'static [&'static str], + pub(crate) score: u8, + pub(crate) position_fit: u8, + pub(crate) attribute_fit: Option, + pub(crate) evidence: Vec, + pub(crate) phase: &'static str, + pub(crate) in_possession_role: Option<&'static str>, + pub(crate) out_of_possession_role: Option<&'static str>, + pub(crate) in_possession_fit: Option, + pub(crate) out_of_possession_fit: Option, + pub(crate) red_flags: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct PlayerRoleEvaluation { + pub(crate) best: Option, + pub(crate) playable: Vec, + pub(crate) secondary: Vec, + pub(crate) reasoning: Vec, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct DecodedRoleDuty { + pub(crate) role: Option<&'static RoleDefinition>, + pub(crate) duty: Option<&'static DutyDefinition>, + pub(crate) unknown_bits: u64, +} + +macro_rules! ip_role { + ($key:expr, $label:expr, $short:expr, $mask:expr, [$($pos:expr),* $(,)?], [$($primary:expr),* $(,)?], [$($secondary:expr),* $(,)?]) => { + RoleDefinition { + key: $key, + label: $label, + short_label: $short, + mask: $mask, + positions: &[$($pos),*], + primary_attributes: &[$($primary),*], + secondary_attributes: &[$($secondary),*], + } + }; +} + +macro_rules! oop_role { + ($key:expr, $label:expr, $short:expr, $mask:expr, [$($pos:expr),* $(,)?], [$($primary:expr),* $(,)?], [$($secondary:expr),* $(,)?]) => { + OutOfPossessionRoleDefinition { + key: $key, + label: $label, + short_label: $short, + mask: $mask, + positions: &[$($pos),*], + primary_attributes: &[$($primary),*], + secondary_attributes: &[$($secondary),*], + } + }; +} + +pub(crate) const ROLE_DEFINITIONS: &[RoleDefinition] = &[ + ip_role!("goalkeeper", "Goalkeeper", "GK", 1, ["GK"], ["Aerial Reach", "Command of Area", "Communication", "Handling", "Reflexes", "Agility", "Concentration", "Positioning"], ["Kicking", "One on Ones", "Throwing", "Anticipation", "Decisions"]), + ip_role!("ball_playing_goalkeeper", "Ball-Playing Goalkeeper", "BPGK", 4_096, ["GK"], ["Aerial Reach", "Command of Area", "Communication", "Handling", "Kicking", "Reflexes", "Agility", "Concentration", "Positioning"], ["Eccentricity", "One on Ones", "Throwing", "Anticipation", "Composure", "Decisions", "Passing"]), + ip_role!("no_nonsense_goalkeeper", "No-Nonsense Goalkeeper", "NNGK", 9_007_199_254_740_992, ["GK"], ["Aerial Reach", "Command of Area", "Communication", "Handling", "Reflexes", "Agility", "Concentration", "Positioning"], ["One on Ones", "Anticipation", "Decisions"]), + ip_role!("centre_back", "Centre-Back", "CB", 2, ["DC"], ["Heading", "Marking", "Tackling", "Anticipation", "Positioning", "Jumping Reach", "Strength"], ["Aggression", "Bravery", "Composure", "Concentration", "Decisions", "Pace"]), + ip_role!("ball_playing_centre_back", "Ball-Playing Centre-Back", "BPCB", 16_777_216, ["DC"], ["Heading", "Marking", "Passing", "Tackling", "Anticipation", "Composure", "Positioning", "Jumping Reach", "Strength"], ["First Touch", "Technique", "Aggression", "Bravery", "Concentration", "Decisions", "Vision", "Pace"]), + ip_role!("no_nonsense_centre_back", "No-Nonsense Centre-Back", "NNCB", 536_870_912, ["DC"], ["Heading", "Marking", "Tackling", "Anticipation", "Positioning", "Jumping Reach", "Strength"], ["Aggression", "Bravery", "Concentration", "Pace"]), + ip_role!("wide_centre_back", "Wide Centre-Back", "WCB", 2_251_799_813_685_248, ["DC"], ["Heading", "Marking", "Tackling", "Anticipation", "Positioning", "Jumping Reach", "Strength"], ["Dribbling", "Aggression", "Bravery", "Composure", "Concentration", "Decisions", "Work Rate", "Acceleration", "Agility", "Pace", "Stamina"]), + ip_role!("advanced_centre_back", "Advanced Centre-Back", "ACB", 16_384, ["DC", "SW"], ["Heading", "Marking", "Passing", "Tackling", "Technique", "Anticipation", "Composure", "Decisions", "Positioning", "Teamwork", "Jumping Reach", "Strength"], ["Dribbling", "First Touch", "Aggression", "Bravery", "Concentration", "Vision", "Pace", "Stamina"]), + ip_role!("overlapping_centre_back", "Overlapping Centre-Back", "OCB", 18_014_398_509_481_984, ["DC"], ["Crossing", "Heading", "Marking", "Tackling", "Anticipation", "Work Rate", "Jumping Reach", "Pace", "Stamina", "Strength"], ["Dribbling", "Technique", "Aggression", "Bravery", "Composure", "Concentration", "Decisions", "Off the Ball", "Positioning", "Acceleration", "Agility"]), + ip_role!("full_back", "Full-Back", "FB", 4, ["DL", "DR"], ["Marking", "Tackling", "Anticipation", "Concentration", "Positioning", "Teamwork", "Acceleration"], ["Crossing", "Dribbling", "Passing", "Technique", "Decisions", "Work Rate", "Agility", "Pace", "Stamina"]), + ip_role!("inside_full_back", "Inside Full-Back", "IFB", 4_503_599_627_370_496, ["DL", "DR"], ["Heading", "Marking", "Tackling", "Anticipation", "Positioning", "Strength"], ["Dribbling", "Aggression", "Bravery", "Composure", "Concentration", "Decisions", "Work Rate", "Acceleration", "Agility", "Jumping Reach", "Pace", "Stamina"]), + ip_role!("inside_wing_back", "Inside Wing-Back", "IWB", 17_592_186_044_416, ["DL", "DR", "WBL", "WBR"], ["Passing", "Tackling", "Anticipation", "Composure", "Decisions", "Positioning", "Teamwork", "Acceleration"], ["First Touch", "Marking", "Technique", "Concentration", "Work Rate", "Agility", "Pace", "Stamina"]), + ip_role!("playmaking_wing_back", "Playmaking Wing-Back", "PWB", 36_028_797_018_963_968, ["DL", "DR", "WBL", "WBR"], ["First Touch", "Passing", "Tackling", "Technique", "Composure", "Decisions", "Positioning", "Teamwork", "Vision", "Acceleration"], ["Crossing", "Dribbling", "Marking", "Anticipation", "Concentration", "Off the Ball", "Work Rate", "Agility", "Pace", "Stamina"]), + ip_role!("wing_back", "Wing-Back", "WB", 8, ["DL", "DR", "WBL", "WBR"], ["Crossing", "Marking", "Tackling", "Teamwork", "Work Rate", "Acceleration", "Pace", "Stamina"], ["Dribbling", "First Touch", "Passing", "Technique", "Anticipation", "Concentration", "Decisions", "Off the Ball", "Positioning", "Agility", "Balance"]), + ip_role!("advanced_wing_back", "Advanced Wing-Back", "AWB", 274_877_906_944, ["DL", "DR", "WBL", "WBR"], ["Crossing", "Dribbling", "Technique", "Off the Ball", "Teamwork", "Work Rate", "Acceleration", "Agility", "Pace", "Stamina"], ["First Touch", "Marking", "Passing", "Tackling", "Anticipation", "Decisions", "Flair", "Positioning", "Balance"]), + ip_role!("defensive_midfielder", "Defensive Midfielder", "DM", 16, ["DM"], ["Tackling", "Anticipation", "Concentration", "Positioning", "Teamwork"], ["First Touch", "Marking", "Passing", "Aggression", "Composure", "Decisions", "Work Rate", "Stamina", "Strength"]), + ip_role!("box_to_box_midfielder", "Box-to-Box Midfielder", "BBM", 65_536, ["DM", "MC"], ["Passing", "Tackling", "Off the Ball", "Teamwork", "Work Rate", "Stamina"], ["Dribbling", "Finishing", "First Touch", "Long Shots", "Technique", "Aggression", "Anticipation", "Composure", "Decisions", "Positioning", "Acceleration", "Balance", "Pace", "Strength"]), + ip_role!("box_to_box_playmaker", "Box-to-Box Playmaker", "BBP", 70_368_744_177_664, ["DM", "MC"], ["First Touch", "Passing", "Technique", "Composure", "Decisions", "Off the Ball", "Teamwork", "Vision", "Work Rate", "Stamina"], ["Dribbling", "Marking", "Tackling", "Anticipation", "Positioning", "Acceleration", "Agility", "Balance", "Pace"]), + ip_role!("deep_lying_playmaker", "Deep-Lying Playmaker", "DLP", 32_768, ["DM", "MC"], ["First Touch", "Passing", "Technique", "Composure", "Decisions", "Off the Ball", "Teamwork", "Vision"], ["Marking", "Tackling", "Anticipation", "Concentration", "Positioning", "Work Rate", "Balance", "Stamina"]), + ip_role!("half_back", "Half-Back", "HB", 34_359_738_368, ["DM"], ["Heading", "Marking", "Tackling", "Anticipation", "Concentration", "Positioning", "Teamwork", "Jumping Reach", "Strength"], ["First Touch", "Passing", "Aggression", "Bravery", "Composure", "Decisions", "Work Rate", "Stamina"]), + ip_role!("central_midfielder", "Central Midfielder", "CM", 32, ["MC"], ["First Touch", "Passing", "Tackling", "Decisions", "Teamwork"], ["Technique", "Anticipation", "Composure", "Concentration", "Off the Ball", "Positioning", "Vision", "Work Rate", "Stamina"]), + ip_role!("advanced_playmaker", "Advanced Playmaker", "AP", 131_072, ["MC", "AMC"], ["First Touch", "Passing", "Technique", "Composure", "Decisions", "Off the Ball", "Teamwork", "Vision"], ["Crossing", "Dribbling", "Anticipation", "Flair", "Acceleration", "Agility"]), + ip_role!("midfield_playmaker", "Midfield Playmaker", "MP", 144_115_188_075_855_872, ["MC"], ["First Touch", "Passing", "Technique", "Composure", "Decisions", "Off the Ball", "Teamwork", "Vision"], ["Dribbling", "Tackling", "Anticipation", "Flair", "Positioning", "Work Rate", "Agility", "Stamina"]), + ip_role!("wide_central_midfielder", "Wide Central Midfielder", "WCM", 281_474_976_710_656, ["MC"], ["First Touch", "Passing", "Tackling", "Decisions", "Teamwork"], ["Crossing", "Dribbling", "Technique", "Anticipation", "Composure", "Concentration", "Off the Ball", "Positioning", "Vision", "Work Rate", "Agility", "Stamina"]), + ip_role!("wide_midfielder", "Wide Midfielder", "WM", 64, ["ML", "MR"], ["Crossing", "Passing", "Technique", "Teamwork", "Work Rate", "Pace", "Stamina"], ["Dribbling", "First Touch", "Anticipation", "Composure", "Off the Ball", "Vision", "Acceleration", "Agility"]), + ip_role!("inside_winger", "Inside Winger", "IW", 562_949_953_421_312, ["ML", "MR", "AML", "AMR"], ["Dribbling", "First Touch", "Technique", "Composure", "Teamwork", "Acceleration", "Agility"], ["Crossing", "Long Shots", "Passing", "Anticipation", "Flair", "Off the Ball", "Vision", "Work Rate", "Balance", "Pace", "Stamina"]), + ip_role!("playmaking_winger", "Playmaking Winger", "PW", 8_796_093_022_208, ["ML", "MR", "AML", "AMR"], ["Crossing", "Dribbling", "First Touch", "Passing", "Technique", "Composure", "Decisions", "Off the Ball", "Teamwork", "Vision", "Acceleration"], ["Anticipation", "Flair", "Work Rate", "Agility", "Pace", "Stamina"]), + ip_role!("winger", "Winger", "W", 128, ["ML", "MR", "AML", "AMR"], ["Crossing", "Dribbling", "Technique", "Teamwork", "Acceleration", "Agility", "Pace"], ["First Touch", "Passing", "Anticipation", "Flair", "Off the Ball", "Work Rate", "Balance", "Stamina"]), + ip_role!("attacking_midfielder", "Attacking Midfielder", "AM", 512, ["AMC"], ["First Touch", "Long Shots", "Passing", "Technique", "Composure", "Flair", "Off the Ball"], ["Crossing", "Dribbling", "Finishing", "Anticipation", "Decisions", "Vision", "Acceleration", "Agility"]), + ip_role!("channel_midfielder", "Channel Midfielder", "CHM", 72_057_594_037_927_936, ["AMC", "MC"], ["Crossing", "First Touch", "Passing", "Technique", "Composure", "Off the Ball", "Work Rate", "Acceleration"], ["Dribbling", "Long Shots", "Anticipation", "Decisions", "Flair", "Vision", "Agility", "Pace", "Stamina"]), + ip_role!("free_role", "Free Role", "FR", 4_294_967_296, ["AMC"], ["Dribbling", "First Touch", "Long Shots", "Passing", "Technique", "Composure", "Flair", "Off the Ball", "Vision"], ["Crossing", "Finishing", "Anticipation", "Decisions", "Acceleration", "Agility"]), + ip_role!("second_striker", "Second Striker", "SS", 2_199_023_255_552, ["AMC"], ["Finishing", "First Touch", "Anticipation", "Composure", "Off the Ball", "Acceleration"], ["Dribbling", "Long Shots", "Passing", "Technique", "Concentration", "Decisions", "Work Rate", "Agility", "Pace", "Stamina"]), + ip_role!("wide_forward", "Wide Forward", "WF", 35_184_372_088_832, ["AML", "AMR"], ["Dribbling", "First Touch", "Technique", "Anticipation", "Off the Ball", "Acceleration", "Agility", "Pace"], ["Crossing", "Finishing", "Passing", "Composure", "Flair", "Work Rate", "Balance", "Stamina"]), + ip_role!("inside_forward", "Inside Forward", "IF", 134_217_728, ["AML", "AMR"], ["Dribbling", "First Touch", "Technique", "Anticipation", "Composure", "Off the Ball", "Acceleration", "Agility"], ["Crossing", "Finishing", "Long Shots", "Passing", "Flair", "Vision", "Work Rate", "Balance", "Pace", "Stamina"]), + ip_role!("centre_forward", "Centre Forward", "CF", 2_048, ["ST"], ["Finishing", "First Touch", "Heading", "Technique", "Composure", "Off the Ball", "Acceleration", "Strength"], ["Dribbling", "Passing", "Anticipation", "Decisions", "Agility", "Balance", "Jumping Reach", "Pace"]), + ip_role!("channel_forward", "Channel Forward", "CHF", 2_147_483_648, ["ST"], ["Dribbling", "Finishing", "First Touch", "Technique", "Composure", "Off the Ball", "Work Rate", "Acceleration"], ["Crossing", "Heading", "Passing", "Anticipation", "Decisions", "Agility", "Balance", "Pace", "Stamina"]), + ip_role!("deep_lying_forward", "Deep-Lying Forward", "DLF", 1_024, ["ST"], ["Finishing", "First Touch", "Technique", "Composure", "Off the Ball", "Strength"], ["Dribbling", "Passing", "Anticipation", "Decisions", "Teamwork", "Vision", "Balance"]), + ip_role!("false_nine", "False Nine", "F9", 1_099_511_627_776, ["ST"], ["Dribbling", "First Touch", "Passing", "Technique", "Composure", "Decisions", "Off the Ball", "Teamwork", "Vision", "Acceleration"], ["Finishing", "Anticipation", "Flair", "Agility", "Balance"]), + ip_role!("poacher", "Poacher", "P", 524_288, ["ST"], ["Finishing", "Heading", "Anticipation", "Composure", "Concentration", "Off the Ball", "Acceleration"], ["First Touch", "Technique", "Decisions", "Balance"]), + ip_role!("target_forward", "Target Forward", "TF", 262_144, ["ST"], ["Finishing", "Heading", "Aggression", "Bravery", "Composure", "Off the Ball", "Balance", "Jumping Reach", "Strength"], ["First Touch", "Anticipation", "Decisions", "Teamwork"]), +]; + +pub(crate) const DUTY_DEFINITIONS: &[DutyDefinition] = &[]; + +pub(crate) const OUT_OF_POSSESSION_ROLE_DEFINITIONS: &[OutOfPossessionRoleDefinition] = &[ + oop_role!("line_holding_keeper", "Line-Holding Keeper", "LHK", 1, ["GK"], ["Positioning", "Concentration"], []), + oop_role!("sweeper_keeper", "Sweeper Keeper", "SK", 2, ["GK"], ["Rushing Out", "Anticipation", "Decisions"], []), + oop_role!("covering_centre_back", "Covering Centre-Back", "CCB", 4, ["DC"], ["Anticipation", "Pace", "Marking"], []), + oop_role!("stopping_centre_back", "Stopping Centre-Back", "SCB", 8, ["DC"], ["Aggression", "Tackling", "Strength"], []), + oop_role!("covering_wide_centre_back", "Covering Wide Centre-Back", "CWCB", 16, ["DC"], ["Anticipation", "Pace", "Marking"], []), + oop_role!("stopping_wide_centre_back", "Stopping Wide Centre-Back", "SWCB", 32, ["DC"], ["Aggression", "Tackling", "Strength"], []), + oop_role!("holding_full_back", "Holding Full-Back", "HFB", 64, ["DL", "DR"], ["Positioning", "Concentration", "Marking"], []), + oop_role!("pressing_full_back", "Pressing Full-Back", "PFB", 128, ["DL", "DR"], ["Aggression", "Work Rate", "Anticipation"], []), + oop_role!("holding_wing_back", "Holding Wing-Back", "HWB", 256, ["DL", "DR", "WBL", "WBR"], ["Positioning", "Concentration", "Marking"], []), + oop_role!("pressing_wing_back", "Pressing Wing-Back", "PWB", 512, ["DL", "DR", "WBL", "WBR"], ["Aggression", "Work Rate", "Anticipation"], []), + oop_role!("dropping_defensive_midfielder", "Dropping Defensive Midfielder", "DDM", 1_024, ["DM"], ["Positioning", "Decisions", "Anticipation"], []), + oop_role!("pressing_defensive_midfielder", "Pressing Defensive Midfielder", "PDM", 2_048, ["DM"], ["Aggression", "Work Rate", "Anticipation"], []), + oop_role!("screening_defensive_midfielder", "Screening Defensive Midfielder", "SDM", 4_096, ["DM"], ["Positioning", "Concentration", "Marking"], []), + oop_role!("wide_covering_defensive_midfielder", "Wide Covering Defensive Midfielder", "WCDM", 8_192, ["DM"], ["Anticipation", "Pace", "Work Rate"], []), + oop_role!("pressing_central_midfielder", "Pressing Central Midfielder", "PCM", 16_384, ["MC"], ["Aggression", "Work Rate", "Anticipation"], []), + oop_role!("screening_central_midfielder", "Screening Central Midfielder", "SCM", 32_768, ["MC"], ["Positioning", "Concentration", "Marking"], []), + oop_role!("wide_covering_central_midfielder", "Wide Covering Central Midfielder", "WCCM", 65_536, ["MC"], ["Anticipation", "Pace", "Work Rate"], []), + oop_role!("tracking_wide_midfielder", "Tracking Wide Midfielder", "TWM", 131_072, ["ML", "MR"], ["Marking", "Work Rate", "Stamina"], []), + oop_role!("wide_outlet_wide_midfielder", "Wide Outlet Wide Midfielder", "WOWM", 262_144, ["ML", "MR"], ["Off the Ball", "Pace", "Anticipation"], []), + oop_role!("tracking_winger", "Tracking Winger", "TW", 524_288, ["ML", "MR", "AML", "AMR"], ["Marking", "Work Rate", "Stamina"], []), + oop_role!("wide_outlet_winger", "Wide Outlet Winger", "WOW", 1_048_576, ["ML", "MR", "AML", "AMR"], ["Off the Ball", "Pace", "Anticipation"], []), + oop_role!("inside_outlet_winger", "Inside Outlet Winger", "IOW", 2_097_152, ["ML", "MR", "AML", "AMR"], ["Off the Ball", "Decisions", "Anticipation"], []), + oop_role!("central_outlet_attacking_midfielder", "Central Outlet Attacking Midfielder", "COAM", 4_194_304, ["AMC"], ["Off the Ball", "Decisions", "Anticipation"], []), + oop_role!("splitting_outlet_attacking_midfielder", "Splitting Outlet Attacking Midfielder", "SOAM", 8_388_608, ["AMC"], ["Off the Ball", "Pace", "Anticipation"], []), + oop_role!("tracking_attacking_midfielder", "Tracking Attacking Midfielder", "TAM", 16_777_216, ["AMC"], ["Marking", "Work Rate", "Stamina"], []), + oop_role!("central_outlet_centre_forward", "Central Outlet Centre Forward", "COCF", 33_554_432, ["ST"], ["Off the Ball", "Decisions", "Anticipation"], []), + oop_role!("splitting_outlet_centre_forward", "Splitting Outlet Centre Forward", "SOCF", 67_108_864, ["ST"], ["Off the Ball", "Pace", "Anticipation"], []), + oop_role!("tracking_centre_forward", "Tracking Centre Forward", "TCF", 134_217_728, ["ST"], ["Marking", "Work Rate", "Stamina"], []), +]; + +pub(crate) fn evaluate_player_roles( + position_bytes: &[u8], + attributes: &HashMap, +) -> PlayerRoleEvaluation { + let mut ip_fits = ROLE_DEFINITIONS + .iter() + .filter_map(|role| score_in_possession_role(role, position_bytes, attributes)) + .collect::>(); + let mut oop_fits = OUT_OF_POSSESSION_ROLE_DEFINITIONS + .iter() + .filter_map(|role| score_out_of_possession_role(role, position_bytes, attributes)) + .collect::>(); + sort_fits(&mut ip_fits); + sort_fits(&mut oop_fits); + + let mut combined = combine_phase_roles(&ip_fits, &oop_fits, attributes); + sort_fits(&mut combined); + + let best = combined + .first() + .cloned() + .or_else(|| ip_fits.first().cloned()) + .or_else(|| oop_fits.first().cloned()); + let playable = combined + .iter() + .filter(|fit| fit.position_fit >= 55 && fit.score >= 45) + .take(8) + .cloned() + .collect::>(); + let secondary = combined + .iter() + .filter(|fit| fit.position_fit >= 30 && fit.score >= 35) + .skip(playable.len()) + .take(8) + .cloned() + .collect::>(); + + let reasoning = match &best { + Some(fit) if fit.phase == "combined" => vec![ + format!( + "{} is the highest FM26 combined tactical fit from split IP/OOP role scoring.", + fit.role + ), + format!( + "IP fit {}%, OOP fit {}%, combined {}%.", + fit.in_possession_fit + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + fit.out_of_possession_fit + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + fit.score + ), + "FM26 has no old Defend/Support/Attack duty model here; GlassScout scores role behaviour separately by phase.".to_string(), + ], + Some(fit) => vec![ + format!("{} is the highest FM26 {} role fit.", fit.role, fit.phase), + "Only one phase could be scored from the mapped position/attribute evidence.".to_string(), + ], + None => vec![ + "No FM26 role could be scored from the mapped position and attribute data.".to_string(), + ], + }; + + PlayerRoleEvaluation { + best, + playable, + secondary, + reasoning, + } +} + +pub(crate) fn decode_role_duty_mask(value: u64) -> DecodedRoleDuty { + let role = ROLE_DEFINITIONS + .iter() + .find(|definition| value & definition.mask == definition.mask && definition.mask != 0); + let known_role_bits = role.map(|definition| definition.mask).unwrap_or_default(); + DecodedRoleDuty { + role, + duty: None, + unknown_bits: value & !known_role_bits, + } +} + +pub(crate) fn role_definition_for_mask(mask: u64) -> Option<&'static RoleDefinition> { + ROLE_DEFINITIONS + .iter() + .find(|definition| definition.mask == mask) +} + +pub(crate) fn duty_definition_for_mask(_mask: u64) -> Option<&'static DutyDefinition> { + None +} + +pub(crate) fn role_supports_slot(role: &RoleDefinition, slot: &str) -> bool { + canonical_slot(slot) + .map(|slot| role.positions.iter().any(|position| *position == slot)) + .unwrap_or(false) +} + +pub(crate) fn role_catalogue_status() -> serde_json::Value { + json!({ + "inPossessionRoleCount": ROLE_DEFINITIONS.len(), + "dutyCount": DUTY_DEFINITIONS.len(), + "outOfPossessionRoleCount": OUT_OF_POSSESSION_ROLE_DEFINITIONS.len(), + "dutyModel": "fm26-phase-roles-no-defend-support-attack", + "combinedFitFormula": "IP 60% + OOP 40%", + "inPossessionRoles": ROLE_DEFINITIONS + .iter() + .map(|role| role_json(role)) + .collect::>(), + "outOfPossessionRoles": OUT_OF_POSSESSION_ROLE_DEFINITIONS + .iter() + .map(|role| oop_role_json(role)) + .collect::>(), + "source": "FM26 split in-possession/out-of-possession role model from project role notes", + "status": "fm26-phase-catalogue-mapped" + }) +} + +fn sort_fits(fits: &mut [PlayerRoleFit]) { + fits.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| right.position_fit.cmp(&left.position_fit)) + .then_with(|| left.role.cmp(&right.role)) + }); +} + +fn score_in_possession_role( + role: &'static RoleDefinition, + position_bytes: &[u8], + attributes: &HashMap, +) -> Option { + let (position_fit, top_position, top_familiarity) = + role_position_fit(role.positions, position_bytes)?; + if position_fit < 5 { + return None; + } + let attribute_fit = weighted_attribute_fit(role.primary_attributes, role.secondary_attributes, attributes); + let score = phase_score(position_fit, attribute_fit); + let mut evidence = role_evidence(role.primary_attributes, role.secondary_attributes, attributes, top_position, top_familiarity); + evidence.insert(0, "In possession role model".to_string()); + Some(PlayerRoleFit { + role_key: role.key.to_string(), + role: role.label.to_string(), + short_role: role.short_label.to_string(), + role_id_mask: format!("0x{:X}", role.mask), + positions: role.positions, + score, + position_fit, + attribute_fit, + evidence, + phase: "in-possession", + in_possession_role: Some(role.label), + out_of_possession_role: None, + in_possession_fit: Some(score), + out_of_possession_fit: None, + red_flags: role_red_flags(role.label, role.primary_attributes, role.secondary_attributes, attributes), + }) +} + +fn score_out_of_possession_role( + role: &'static OutOfPossessionRoleDefinition, + position_bytes: &[u8], + attributes: &HashMap, +) -> Option { + let (position_fit, top_position, top_familiarity) = + role_position_fit(role.positions, position_bytes)?; + if position_fit < 5 { + return None; + } + let attribute_fit = weighted_attribute_fit(role.primary_attributes, role.secondary_attributes, attributes); + let score = phase_score(position_fit, attribute_fit); + let mut evidence = role_evidence(role.primary_attributes, role.secondary_attributes, attributes, top_position, top_familiarity); + evidence.insert(0, "Out of possession role model".to_string()); + Some(PlayerRoleFit { + role_key: role.key.to_string(), + role: role.label.to_string(), + short_role: role.short_label.to_string(), + role_id_mask: format!("OOP:0x{:X}", role.mask), + positions: role.positions, + score, + position_fit, + attribute_fit, + evidence, + phase: "out-of-possession", + in_possession_role: None, + out_of_possession_role: Some(role.label), + in_possession_fit: None, + out_of_possession_fit: Some(score), + red_flags: role_red_flags(role.label, role.primary_attributes, role.secondary_attributes, attributes), + }) +} + +fn combine_phase_roles( + ip_fits: &[PlayerRoleFit], + oop_fits: &[PlayerRoleFit], + attributes: &HashMap, +) -> Vec { + let mut combined = Vec::new(); + for ip in ip_fits.iter().take(14) { + for oop in oop_fits.iter().take(14) { + if !phase_positions_compatible(ip.positions, oop.positions) { + continue; + } + let score = ((f32::from(ip.score) * 0.60) + (f32::from(oop.score) * 0.40)) + .round() + .clamp(0.0, 100.0) as u8; + let position_fit = ((u16::from(ip.position_fit) + u16::from(oop.position_fit)) / 2) as u8; + let attribute_fit = match (ip.attribute_fit, oop.attribute_fit) { + (Some(left), Some(right)) => Some(((u16::from(left) + u16::from(right)) / 2) as u8), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + }; + let mut red_flags = ip.red_flags.clone(); + red_flags.extend(oop.red_flags.clone()); + red_flags.sort(); + red_flags.dedup(); + if red_flags.is_empty() { + red_flags.extend(combined_red_flags(ip, oop, attributes)); + } + let mut evidence = vec![ + format!("IP {} fit {}", ip.role, ip.score), + format!("OOP {} fit {}", oop.role, oop.score), + "Combined tactical fit uses IP 60% and OOP 40%.".to_string(), + ]; + evidence.extend(ip.evidence.iter().skip(1).take(2).cloned()); + evidence.extend(oop.evidence.iter().skip(1).take(2).cloned()); + combined.push(PlayerRoleFit { + role_key: format!("{}_plus_{}", ip.role_key, oop.role_key), + role: format!("{} + {}", ip.role, oop.role), + short_role: format!("{}+{}", ip.short_role, oop.short_role), + role_id_mask: format!("IP:{};OOP:{}", ip.role_id_mask, oop.role_id_mask), + positions: ip.positions, + score, + position_fit, + attribute_fit, + evidence, + phase: "combined", + in_possession_role: ip.in_possession_role, + out_of_possession_role: oop.out_of_possession_role, + in_possession_fit: ip.in_possession_fit, + out_of_possession_fit: oop.out_of_possession_fit, + red_flags, + }); + } + } + combined +} + +fn phase_score(position_fit: u8, attribute_fit: Option) -> u8 { + match attribute_fit { + Some(attribute_fit) => { + (f32::from(position_fit) * 0.34 + f32::from(attribute_fit) * 0.66) + .round() as u8 + } + None => position_fit, + } + .min(position_fit.saturating_add(25)) + .clamp(0, 100) +} + +fn role_position_fit( + positions: &'static [&'static str], + position_bytes: &[u8], +) -> Option<(u8, &'static str, u8)> { + let mut best: Option<(&'static str, u8)> = None; + for position in positions { + let index = POSITION_NAMES.iter().position(|name| name == position)?; + let familiarity = position_bytes.get(index).copied().unwrap_or_default(); + if best.is_none_or(|(_, current)| familiarity > current) { + best = Some((*position, familiarity)); + } + } + let (position, familiarity) = best?; + let fit = ((f32::from(familiarity) / 20.0) * 100.0) + .round() + .clamp(0.0, 100.0) as u8; + Some((fit, position, familiarity)) +} + +fn weighted_attribute_fit( + primary_attributes: &[&str], + secondary_attributes: &[&str], + attributes: &HashMap, +) -> Option { + let mut total = 0_u32; + let mut weight = 0_u32; + let mut primary_total = 0_u32; + let mut primary_count = 0_u32; + let mut weak_primary = 0_u32; + for key in primary_attributes { + if let Some(value) = attributes.get(*key) { + total += u32::from(*value) * 3; + weight += 3; + primary_total += u32::from(*value); + primary_count += 1; + if *value <= 8 { + weak_primary += 1; + } + } + } + for key in secondary_attributes { + if let Some(value) = attributes.get(*key) { + total += u32::from(*value); + weight += 1; + } + } + (weight >= 6).then(|| { + let average = total as f32 / weight as f32; + let mut score = ((average / 20.0) * 100.0).round().clamp(0.0, 100.0) as i16; + if primary_count >= 3 { + let primary_average = primary_total as f32 / primary_count as f32; + if primary_average < 10.0 { + score -= 15; + } + } + if weak_primary >= 2 { + score -= 10; + } + score.clamp(0, 100) as u8 + }) +} + +fn role_evidence( + primary_attributes: &[&str], + secondary_attributes: &[&str], + attributes: &HashMap, + top_position: &'static str, + top_familiarity: u8, +) -> Vec { + let mut evidence = vec![format!("{top_position} familiarity {top_familiarity}/20")]; + let mut values = primary_attributes + .iter() + .chain(secondary_attributes.iter()) + .filter_map(|key| { + attributes + .get(*key) + .map(|value| ((*key).to_string(), *value)) + }) + .collect::>(); + values.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + evidence.extend( + values + .into_iter() + .take(3) + .map(|(attribute, value)| format!("{attribute} {value}")), + ); + evidence +} + +fn role_red_flags( + label: &str, + primary_attributes: &[&str], + secondary_attributes: &[&str], + attributes: &HashMap, +) -> Vec { + let mut flags = Vec::new(); + let weak = |attribute: &str| attributes.get(attribute).is_some_and(|value| *value <= 9); + let any_weak_primary = primary_attributes.iter().any(|attribute| weak(attribute)); + if label.contains("Pressing") + && ["Aggression", "Work Rate", "Anticipation"] + .iter() + .any(|attribute| weak(attribute)) + { + flags.push("Pressing fraud: pressing role has weak aggression, work rate or anticipation.".to_string()); + } + if label.contains("Covering") + && ["Pace", "Anticipation"] + .iter() + .any(|attribute| weak(attribute)) + { + flags.push("High-line risk: covering role lacks pace or anticipation.".to_string()); + } + if (label.contains("Playmaker") + || label.contains("Playmaking") + || label.contains("Free Role") + || label.contains("False Nine")) + && ["Passing", "Decisions", "Vision", "Technique"] + .iter() + .any(|attribute| weak(attribute)) + { + flags.push("Creative fraud: creative role has weak passing, decisions, vision or technique.".to_string()); + } + if (label.contains("Centre-Back") || label.contains("Target")) + && ["Strength", "Jumping Reach", "Heading"] + .iter() + .any(|attribute| weak(attribute)) + { + flags.push("Physical mismatch: aerial/physical role has weak strength, jumping or heading.".to_string()); + } + if ["Marking", "Work Rate", "Stamina"] + .iter() + .all(|attribute| primary_attributes.contains(attribute) || secondary_attributes.contains(attribute)) + && ["Marking", "Work Rate", "Stamina"] + .iter() + .any(|attribute| weak(attribute)) + { + flags.push("Bad defensive match: tracking role lacks marking, work rate or stamina.".to_string()); + } + if any_weak_primary { + flags.push("Weak VI attribute: one or more very important role attributes are below the safe threshold.".to_string()); + } + flags +} + +fn combined_red_flags( + ip: &PlayerRoleFit, + oop: &PlayerRoleFit, + attributes: &HashMap, +) -> Vec { + let mut flags = Vec::new(); + if oop.role.contains("Tracking") + && ["Marking", "Work Rate", "Stamina"] + .iter() + .any(|attribute| attributes.get(*attribute).is_some_and(|value| *value <= 9)) + { + flags.push(format!("Bad defensive match for {}", oop.role)); + } + if ip.role.contains("Playmaker") + && ["Passing", "Composure", "Decisions"] + .iter() + .any(|attribute| attributes.get(*attribute).is_some_and(|value| *value <= 9)) + { + flags.push(format!("Bad build-up match for {}", ip.role)); + } + flags +} + +fn phase_positions_compatible(left: &'static [&'static str], right: &'static [&'static str]) -> bool { + left.iter().any(|left_position| { + right.iter().any(|right_position| { + canonical_slot(left_position) == canonical_slot(right_position) + || position_family(left_position) == position_family(right_position) + }) + }) +} + +fn role_json(role: &RoleDefinition) -> serde_json::Value { + json!({ + "key": role.key, + "role": role.label, + "shortRole": role.short_label, + "roleIdMask": format!("0x{:X}", role.mask), + "positions": role.positions, + "phase": "in-possession", + "veryImportant": role.primary_attributes, + "important": role.secondary_attributes, + }) +} + +fn oop_role_json(role: &OutOfPossessionRoleDefinition) -> serde_json::Value { + json!({ + "key": role.key, + "role": role.label, + "shortRole": role.short_label, + "roleIdMask": format!("OOP:0x{:X}", role.mask), + "positions": role.positions, + "phase": "out-of-possession", + "veryImportant": role.primary_attributes, + "important": role.secondary_attributes, + }) +} + +fn position_family(slot: &str) -> Option<&'static str> { + Some(match canonical_slot(slot)? { + "GK" => "GK", + "SW" | "DC" => "DC", + "DL" | "DR" | "WBL" | "WBR" => "FB", + "DM" => "DM", + "MC" => "MC", + "ML" | "MR" | "AML" | "AMR" => "WIDE", + "AMC" => "AMC", + "ST" => "ST", + _ => return None, + }) +} + +fn canonical_slot(slot: &str) -> Option<&'static str> { + Some(match slot { + "GK" => "GK", + "SW" => "SW", + "DC" | "DCR" | "DCL" => "DC", + "DL" => "DL", + "DR" => "DR", + "WBL" => "WBL", + "WBR" => "WBR", + "DM" | "DML" | "DMR" => "DM", + "MC" | "MCL" | "MCR" => "MC", + "ML" => "ML", + "MR" => "MR", + "AMC" | "AMCL" | "AMCR" => "AMC", + "AML" => "AML", + "AMR" => "AMR", + "ST" | "STC" | "STL" | "STR" | "STCL" | "STCR" => "ST", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fm26_role_catalogue_is_phase_split_without_legacy_duties() { + assert!(role_definition_for_mask(16_777_216).is_some_and(|role| role.label == "Ball-Playing Centre-Back")); + assert_eq!(DUTY_DEFINITIONS.len(), 0); + assert!(duty_definition_for_mask(4_194_304).is_none()); + assert!(OUT_OF_POSSESSION_ROLE_DEFINITIONS.len() >= 28); + let ip_labels = ROLE_DEFINITIONS + .iter() + .map(|role| role.label) + .collect::>(); + assert!(ip_labels.contains(&"Inside Wing-Back")); + assert!(ip_labels.contains(&"Free Role")); + assert!(!ip_labels.contains(&"Mezzala")); + assert!(!ip_labels.contains(&"Enganche")); + assert!(!ip_labels.contains(&"Trequartista")); + assert!(!ip_labels.contains(&"Complete Forward")); + } + + #[test] + fn centre_back_scores_phase_split_centre_back_roles() { + let mut positions = vec![1_u8; POSITION_NAMES.len()]; + positions[POSITION_NAMES + .iter() + .position(|name| *name == "DC") + .unwrap()] = 20; + let attrs = HashMap::from([ + ("Marking".to_string(), 14), + ("Tackling".to_string(), 13), + ("Heading".to_string(), 15), + ("Positioning".to_string(), 12), + ("Anticipation".to_string(), 13), + ("Jumping Reach".to_string(), 14), + ("Strength".to_string(), 13), + ("Passing".to_string(), 7), + ("Technique".to_string(), 6), + ("Pace".to_string(), 12), + ]); + let evaluation = evaluate_player_roles(&positions, &attrs); + let best = evaluation.best.expect("best role"); + assert!(best.phase == "combined"); + assert!(best.in_possession_role.is_some()); + assert!(best.out_of_possession_role.is_some()); + assert!(best.role.contains("Centre-Back")); + assert!(best.score >= 60); + } + + #[test] + fn weak_tracking_attributes_create_red_flag() { + let mut positions = vec![1_u8; POSITION_NAMES.len()]; + positions[POSITION_NAMES + .iter() + .position(|name| *name == "AMR") + .unwrap()] = 20; + let attrs = HashMap::from([ + ("Crossing".to_string(), 16), + ("Dribbling".to_string(), 16), + ("Technique".to_string(), 15), + ("Teamwork".to_string(), 14), + ("Acceleration".to_string(), 15), + ("Agility".to_string(), 15), + ("Pace".to_string(), 15), + ("Marking".to_string(), 5), + ("Work Rate".to_string(), 6), + ("Stamina".to_string(), 7), + ("Anticipation".to_string(), 12), + ("Off the Ball".to_string(), 14), + ("Decisions".to_string(), 12), + ]); + let tracking_winger = OUT_OF_POSSESSION_ROLE_DEFINITIONS + .iter() + .find(|role| role.label == "Tracking Winger") + .expect("tracking winger role"); + let scored = score_out_of_possession_role(tracking_winger, &positions, &attrs) + .expect("scored tracking winger"); + assert!(scored + .red_flags + .iter() + .any(|flag| flag.contains("Bad defensive match"))); + } +} diff --git a/src-tauri/src/fm26/scanner.rs b/src-tauri/src/fm26/scanner.rs new file mode 100644 index 0000000..2c6a3a4 --- /dev/null +++ b/src-tauri/src/fm26/scanner.rs @@ -0,0 +1,131 @@ +#[cfg(target_os = "windows")] +use super::memory::{ModuleInfo, ProcessReader}; +#[cfg(target_os = "windows")] +use std::collections::{HashMap, HashSet}; + +#[derive(Debug)] +pub(crate) struct PatternParseError; + +#[derive(Debug)] +pub(crate) struct ScanError { + message: String, +} + +impl ScanError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for ScanError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +pub(crate) fn parse_pattern(pattern: &str) -> Result>, PatternParseError> { + pattern + .split_whitespace() + .map(|token| { + if token == "??" || token == "?" { + Ok(None) + } else { + u8::from_str_radix(token, 16) + .map(Some) + .map_err(|_| PatternParseError) + } + }) + .collect() +} + +#[cfg(target_os = "windows")] +pub(crate) fn scan_module( + reader: &mut ProcessReader, + module: ModuleInfo, + pattern: &[Option], +) -> Result, ScanError> { + if pattern.is_empty() || module.size < pattern.len() { + return Err(ScanError::new( + "The manager signature is empty or larger than the game module.", + )); + } + const CHUNK_SIZE: usize = 4 * 1024 * 1024; + let overlap = pattern.len().saturating_sub(1); + let mut hits = Vec::new(); + let mut offset = 0_usize; + while offset < module.size { + let read_start = offset.saturating_sub(if offset == 0 { 0 } else { overlap }); + let read_size = CHUNK_SIZE + .saturating_add(if offset == 0 { 0 } else { overlap }) + .min(module.size - read_start); + let bytes = reader + .read_bytes(module.base + read_start as u64, read_size) + .ok_or_else(|| { + ScanError::new("The FM26 game module could not be scanned with read-only access.") + })?; + for position in 0..=bytes.len().saturating_sub(pattern.len()) { + let absolute_offset = read_start + position; + if offset != 0 && absolute_offset < offset { + continue; + } + if pattern.iter().enumerate().all(|(index, expected)| { + expected.is_none_or(|value| bytes[position + index] == value) + }) { + hits.push(module.base + absolute_offset as u64); + } + } + offset = offset.saturating_add(CHUNK_SIZE); + } + Ok(hits) +} + +#[cfg(target_os = "windows")] +pub(crate) fn scan_private_memory_for_pointers( + reader: &mut ProcessReader, + pointers: &[u64], +) -> Result>, ScanError> { + const CHUNK_SIZE: usize = 8 * 1024 * 1024; + const MAX_SCAN_BYTES: usize = 8 * 1024 * 1024 * 1024; + + let regions = reader.readable_private_regions(MAX_SCAN_BYTES); + if regions.is_empty() { + return Err(ScanError::new( + "No readable FM26 private-memory regions were available for player indexing.", + )); + } + + let needles: HashSet = pointers.iter().copied().collect(); + let mut hits: HashMap> = pointers + .iter() + .copied() + .map(|pointer| (pointer, Vec::new())) + .collect(); + for region in regions { + let mut offset = 0_usize; + while offset < region.size { + let size = CHUNK_SIZE.min(region.size - offset); + let Some(bytes) = reader.read_bytes(region.base + offset as u64, size) else { + offset = offset.saturating_add(size); + continue; + }; + let base = region.base + offset as u64; + let first_aligned = ((8 - (base as usize & 7)) & 7).min(bytes.len()); + let mut position = first_aligned; + while position + 8 <= bytes.len() { + let value = u64::from_le_bytes( + bytes[position..position + 8] + .try_into() + .expect("eight-byte aligned memory candidate"), + ); + if needles.contains(&value) { + hits.entry(value).or_default().push(base + position as u64); + } + position += 8; + } + offset = offset.saturating_add(size); + } + } + Ok(hits) +} diff --git a/src-tauri/src/fm26/structs.rs b/src-tauri/src/fm26/structs.rs new file mode 100644 index 0000000..313e0ff --- /dev/null +++ b/src-tauri/src/fm26/structs.rs @@ -0,0 +1,68 @@ +pub(crate) const POSITION_NAMES: [&str; 15] = [ + "GK", "SW", "DL", "DC", "DR", "DM", "ML", "MC", "MR", "AML", "AMC", "AMR", "ST", "WBL", "WBR", +]; + +pub(crate) 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", +]; + +pub(crate) const HIDDEN_ATTRIBUTE_INDEXES: [usize; 5] = [41, 44, 47, 48, 49]; + +#[derive(Clone, Copy)] +pub(crate) struct FmDate { + pub(crate) year: u16, + pub(crate) day_of_year: u16, +} diff --git a/src-tauri/src/fm26/tactics.rs b/src-tauri/src/fm26/tactics.rs new file mode 100644 index 0000000..601a5ba --- /dev/null +++ b/src-tauri/src/fm26/tactics.rs @@ -0,0 +1,222 @@ +pub(crate) struct FormationTemplate { + pub(crate) enum_name: &'static str, + pub(crate) label: &'static str, + pub(crate) positions: &'static [&'static str], + pub(crate) exact_shape: bool, +} + +const GENERIC_XI: &[&str] = &[ + "GK", "Slot 2", "Slot 3", "Slot 4", "Slot 5", "Slot 6", "Slot 7", "Slot 8", "Slot 9", + "Slot 10", "Slot 11", +]; +const P442: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MR", "MCR", "MCL", "ML", "STCR", "STCL", +]; +const P433_DM_WIDE: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DM", "MCR", "MCL", "AMR", "AML", "ST", +]; +const P424_WIDE: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MCL", "AMR", "AML", "STCR", "STCL", +]; +const P352: &[&str] = &[ + "GK", "DCR", "DC", "DCL", "MR", "MCR", "MC", "MCL", "ML", "STCR", "STCL", +]; +const P41212: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DM", "MCR", "MCL", "AMC", "STCR", "STCL", +]; +const P451: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MR", "MCR", "MC", "MCL", "ML", "ST", +]; +const P343: &[&str] = &[ + "GK", "DCR", "DC", "DCL", "MR", "MCR", "MCL", "ML", "AMR", "AML", "ST", +]; +const P3412: &[&str] = &[ + "GK", "DCR", "DC", "DCL", "MR", "MCR", "MCL", "ML", "AMC", "STCR", "STCL", +]; +const P3421: &[&str] = &[ + "GK", "DCR", "DC", "DCL", "MR", "MCR", "MCL", "ML", "AMCR", "AMCL", "ST", +]; +const P4321_NARROW: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MC", "MCL", "AMCR", "AMCL", "ST", +]; +const P4312: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MC", "MCL", "AMC", "STCR", "STCL", +]; +const P4411: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MR", "MCR", "MCL", "ML", "AMC", "ST", +]; +const P4231_WIDE: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MCL", "AMR", "AMC", "AML", "ST", +]; +const P4231_NARROW: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MCL", "AMCR", "AMC", "AMCL", "ST", +]; +const P4222_DM_WIDE: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DMR", "DML", "AMR", "AML", "STCR", "STCL", +]; +const P4222_DM_NARROW: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DMR", "DML", "AMCR", "AMCL", "STCR", "STCL", +]; +const P433_DM_NARROW: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DM", "MCR", "MCL", "STR", "STC", "STL", +]; +const P4321_DM_NARROW: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DM", "MCR", "MCL", "AMCR", "AMCL", "ST", +]; +const P4231_DM_WIDE: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DMR", "DML", "AMR", "AMC", "AML", "ST", +]; +const P4141_DM: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "DM", "MR", "MCR", "MCL", "ML", "ST", +]; +const P433_NARROW: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MC", "MCL", "STR", "STC", "STL", +]; +const P424: &[&str] = &[ + "GK", "DR", "DCR", "DCL", "DL", "MCR", "MCL", "AMR", "AML", "STCR", "STCL", +]; +const P541: &[&str] = &[ + "GK", "DR", "DCR", "DC", "DCL", "DL", "MR", "MCR", "MCL", "ML", "ST", +]; +const P5212_WB: &[&str] = &[ + "GK", "WBR", "DCR", "DC", "DCL", "WBL", "MCR", "MCL", "AMC", "STCR", "STCL", +]; +const P5221_WB: &[&str] = &[ + "GK", "WBR", "DCR", "DC", "DCL", "WBL", "MCR", "MCL", "AMR", "AML", "ST", +]; + +pub(crate) fn tactic_formation_template(code: u32) -> Option { + let (enum_name, label) = tactic_formation_name(code)?; + let positions = match code { + 3 => P442, + 4 => P433_DM_WIDE, + 5 => P424_WIDE, + 6 => P352, + 7 => P41212, + 8 => P451, + 9 => P343, + 10 => P3412, + 11 => P3421, + 12 => P4321_NARROW, + 14 => P4312, + 16 => P4411, + 18 => P4231_NARROW, + 21 => P4231_WIDE, + 22 | 28 => P4222_DM_WIDE, + 24 => P4222_DM_NARROW, + 26 | 39 | 40 => P433_DM_NARROW, + 27 => P4321_DM_NARROW, + 29 | 54 => P4231_DM_WIDE, + 32 => P4141_DM, + 36 => P433_NARROW, + 37 => P424, + 38 | 57 | 58 => P541, + 41 | 64 => P5212_WB, + 59 => P5221_WB, + _ => GENERIC_XI, + }; + Some(FormationTemplate { + enum_name, + label, + positions, + exact_shape: positions != GENERIC_XI, + }) +} + +pub(crate) fn tactic_formation_name(code: u32) -> Option<(&'static str, &'static str)> { + Some(match code { + 0 => ("FormationStart", "Formation start"), + 1 => ("Formation532SWWBNotInUse", "5-3-2 SW WB"), + 2 => ("Formation532WB", "5-3-2 WB"), + 3 => ("Formation442", "4-4-2"), + 4 => ("Formation433DMWide", "4-1-2-3 DM Wide"), + 5 => ("Formation424Wide", "4-2-4 Wide"), + 6 => ("Formation352", "3-5-2"), + 7 => ("Formation442DiamondNarrow", "4-1-2-1-2 Diamond Narrow"), + 8 => ("Formation451", "4-5-1"), + 9 => ("Formation343", "3-4-3"), + 10 => ("Formation3412", "3-4-1-2"), + 11 => ("Formation3421", "3-4-2-1"), + 12 => ("Formation4321Narrow", "4-3-2-1 Narrow"), + 13 => ("Formation352SWNotInUse", "3-5-2 SW"), + 14 => ("Formation4312Narrow", "4-3-1-2 Narrow"), + 15 => ("Formation541DiamondWB", "5-4-1 Diamond WB"), + 16 => ("Formation4411", "4-4-1-1"), + 17 => ("Formation442SWNotInUse", "4-4-2 SW"), + 18 => ("Formation4231Narrow", "4-2-3-1 Narrow"), + 19 => ("Formation41311DMNarrow", "4-1-3-1-1 DM Narrow"), + 20 => ("Formation4132DMNarrow", "4-1-3-2 DM Narrow"), + 21 => ("Formation4231Wide", "4-2-3-1 Wide"), + 22 => ("Formation4222DM", "4-2-2-2 DM"), + 23 => ("Formation4332DMWide", "4-3-3-2 DM Wide"), + 24 => ("Formation4222DMNarrow", "4-2-2-2 DM Narrow"), + 25 => ("Formation31312DM", "3-1-3-1-2 DM"), + 26 => ("Formation433DMNarrow", "4-1-2-3 DM Narrow"), + 27 => ("Formation4321DMNarrow", "4-1-2-2-1 DM Narrow"), + 28 => ("Formation424DMWide", "4-2-4 DM Wide"), + 29 => ("Formation4231DMWide", "4-2-3-1 DM Wide"), + 30 => ("Formation5122DMWB", "5-1-2-2 DM WB"), + 31 => ("Formation3421DM", "3-4-2-1 DM"), + 32 => ("Formation4141DM", "4-1-4-1 DM"), + 33 => ("Formation44112DM", "4-4-1-1-2 DM"), + 34 => ("Formation3232DM", "3-2-3-2 DM"), + 35 => ("Formation343DMWide", "3-4-3 DM Wide"), + 36 => ("Formation433Narrow", "4-3-3 Narrow"), + 37 => ("Formation424", "4-2-4"), + 38 => ("Formation541", "5-4-1"), + 39 => ("Formation4123Narrow", "4-1-2-3 Narrow"), + 40 => ("Formation4231DMNarrow", "4-2-3-1 DM Narrow"), + 41 => ("Formation5212WB", "5-2-1-2 WB"), + 42 => ("Formation5122DMNarrow", "5-1-2-2 DM Narrow"), + 43 => ("Formation235", "2-3-5"), + 44 => ("Formation244", "2-4-4"), + 45 => ("Formation325", "3-2-5"), + 46 => ("Formation334", "3-3-4"), + 47 => ("Formation523Narrow", "5-2-3 Narrow"), + 48 => ("Formation5212SWWBNotInUse", "5-2-1-2 SW WB"), + 49 => ("Formation532SWDMWBNotInUse", "5-3-2 SW DM WB"), + 50 => ("Formation4141DMAsymmetricAMR", "4-1-4-1 DM Asymmetric AMR"), + 51 => ("Formation4240DMWide", "4-2-4-0 DM Wide"), + 52 => ("Formation4132DM", "4-1-3-2 DM"), + 53 => ("Formation4141DMAsymmetricAML", "4-1-4-1 DM Asymmetric AML"), + 54 => ("Formation4231DM", "4-2-3-1 DM"), + 55 => ("Formation42310DM", "4-2-3-1-0 DM"), + 56 => ("Formation5131DMWB", "5-1-3-1 DM WB"), + 57 => ("Formation541DM", "5-4-1 DM"), + 58 => ("Formation5410DM", "5-4-1-0 DM"), + 59 => ("Formation5221WB", "5-2-2-1 WB"), + 60 => ("Formation3142DM", "3-1-4-2 DM"), + 61 => ("Formation523WBWide", "5-2-3 WB Wide"), + 62 => ("Formation4222AMNarrow", "4-2-2-2 AM Narrow"), + 63 => ("Formation343DMNarrow", "3-4-3 DM Narrow"), + 64 => ("Formation5212DMWB", "5-2-1-2 DM WB"), + 65 => ("Formation3241", "3-2-4-1"), + 66 => ("Formation5212DMFBNotInUse", "5-2-1-2 DM FB"), + 67 => ("Formation5221DMFBNotInUse", "5-2-2-1 DM FB"), + 68 => ("Formation523DMFBNotInUse", "5-2-3 DM FB"), + 69 => ("Formation532FBNotInUse", "5-3-2 FB"), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fm26_metadata_catalogue_maps_current_live_formation_code() { + let template = tactic_formation_template(4).expect("4-1-2-3 DM Wide"); + assert_eq!(template.enum_name, "Formation433DMWide"); + assert_eq!(template.label, "4-1-2-3 DM Wide"); + assert_eq!(template.positions.len(), 11); + assert!(template.exact_shape); + } + + #[test] + fn fm26_metadata_catalogue_keeps_rare_formations_visible_without_fake_shape() { + let template = tactic_formation_template(65).expect("3-2-4-1"); + assert_eq!(template.enum_name, "Formation3241"); + assert_eq!(template.positions, GENERIC_XI); + assert!(!template.exact_shape); + } +} diff --git a/src-tauri/src/fm26/validator.rs b/src-tauri/src/fm26/validator.rs new file mode 100644 index 0000000..3175679 --- /dev/null +++ b/src-tauri/src/fm26/validator.rs @@ -0,0 +1,25 @@ +#[cfg(target_os = "windows")] +use super::memory::ProcessReader; + +#[cfg(target_os = "windows")] +pub(crate) fn validate_vtable( + reader: &mut ProcessReader, + object: u64, + expected: u64, +) -> Result<(), String> { + let actual = reader + .read_pointer(object) + .ok_or_else(|| "A required FM26 object could not be read.".to_string())?; + if actual != expected { + return Err(format!( + "A required FM26 object failed type validation (expected {}, found {}). No data was shown.", + hex_address(expected), + hex_address(actual) + )); + } + Ok(()) +} + +fn hex_address(value: u64) -> String { + format!("0x{value:X}") +} diff --git a/src-tauri/src/fm_dossier.rs b/src-tauri/src/fm_dossier.rs new file mode 100644 index 0000000..4c0b347 --- /dev/null +++ b/src-tauri/src/fm_dossier.rs @@ -0,0 +1,1072 @@ +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 > 0 { + 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), + "contractRemaining": player.contract_remaining_text, + "averageRating": player.avg_rating, + "minutesPlayed": player.minutes, + "goals": player.goals, + "assists": player.assists, + "transferInterest": Option::::None, + "loanInterest": Option::::None, + "transferAvailable": player.transfer_listed.map(|value| value == 1), + "loanAvailable": player.loan_listed.map(|value| value == 1), + "notForSale": player.not_for_sale.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, + }) +} + +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": Option::::None, + "loanInterest": Option::::None, + "transferAvailable": player.transfer_listed.map(|value| value == 1), + "loanAvailable": player.loan_listed.map(|value| value == 1), + "notForSale": player.not_for_sale.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( + "notForSale".to_string(), + json!(dossier.not_for_sale.map(|value| value == 1)), + ); + object.insert("transferInterest".to_string(), json!(Option::::None)); + object.insert("loanInterest".to_string(), json!(Option::::None)); + 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 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/graphics/config_xml.rs b/src-tauri/src/graphics/config_xml.rs new file mode 100644 index 0000000..1a1491b --- /dev/null +++ b/src-tauri/src/graphics/config_xml.rs @@ -0,0 +1,28 @@ +use std::path::Path; + +pub(crate) const MAX_GRAPHICS_CONFIG_BYTES: u64 = 32 * 1024 * 1024; + +pub(crate) 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) +} + +pub(crate) fn safe_single_component_stem(value: &str) -> Option<&str> { + let relative = Path::new(value); + (relative.components().count() == 1).then_some(value) +} + +#[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")); + assert_eq!(safe_single_component_stem("../bad"), None); + } +} diff --git a/src-tauri/src/graphics/faces.rs b/src-tauri/src/graphics/faces.rs new file mode 100644 index 0000000..2fa318f --- /dev/null +++ b/src-tauri/src/graphics/faces.rs @@ -0,0 +1,92 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use super::config_xml::{attribute_value, safe_single_component_stem, MAX_GRAPHICS_CONFIG_BYTES}; + +pub(crate) const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024; + +pub(crate) 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_face_from_config(&config, &directory, player_id) { + return Some(path); + } + } + None +} + +pub(crate) 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 +} + +pub(crate) 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_from_config(config: &Path, directory: &Path, player_id: &str) -> Option { + let metadata = config.metadata().ok()?; + if metadata.len() > MAX_GRAPHICS_CONFIG_BYTES { + 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 = safe_single_component_stem(attribute_value(line, "from")?)?; + for extension in ["png", "jpg", "jpeg", "webp"] { + let path = directory.join(format!("{from}.{extension}")); + if path.is_file() { + return Some(path); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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/graphics/logos.rs b/src-tauri/src/graphics/logos.rs new file mode 100644 index 0000000..3f4be4f --- /dev/null +++ b/src-tauri/src/graphics/logos.rs @@ -0,0 +1,64 @@ +use std::{fs, path::Path}; + +use super::{ + config_xml::{attribute_value, safe_single_component_stem, MAX_GRAPHICS_CONFIG_BYTES}, + faces::{graphics_roots, image_mime, MAX_IMAGE_BYTES}, +}; + +pub(crate) fn resolve_logo_path(club_id: &str) -> Option { + for root in graphics_roots() { + for directory in [root.join("logos"), root.join("clubs"), root.join("badges")] { + for stem in [ + club_id.to_string(), + format!("club_{club_id}"), + format!("logo_{club_id}"), + ] { + for extension in ["png", "jpg", "jpeg", "webp"] { + let path = directory.join(format!("{stem}.{extension}")); + if valid_image_file(&path) { + return Some(path); + } + } + } + let config = directory.join("config.xml"); + if let Some(path) = resolve_logo_from_config(&config, &directory, club_id) { + return Some(path); + } + } + } + None +} + +fn valid_image_file(path: &Path) -> bool { + image_mime(path).is_some() + && path.metadata().is_ok_and(|metadata| { + metadata.is_file() && metadata.len() > 0 && metadata.len() <= MAX_IMAGE_BYTES + }) +} + +fn resolve_logo_from_config( + config: &Path, + directory: &Path, + club_id: &str, +) -> Option { + let metadata = config.metadata().ok()?; + if metadata.len() > MAX_GRAPHICS_CONFIG_BYTES { + return None; + } + let content = fs::read_to_string(config).ok()?; + for marker in [ + format!("team/{club_id}/logo"), + format!("club/{club_id}/logo"), + ] { + for line in content.lines().filter(|line| line.contains(&marker)) { + let from = safe_single_component_stem(attribute_value(line, "from")?)?; + for extension in ["png", "jpg", "jpeg", "webp"] { + let path = directory.join(format!("{from}.{extension}")); + if valid_image_file(&path) { + return Some(path); + } + } + } + } + None +} diff --git a/src-tauri/src/graphics/mod.rs b/src-tauri/src/graphics/mod.rs new file mode 100644 index 0000000..588214e --- /dev/null +++ b/src-tauri/src/graphics/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod config_xml; +pub(crate) mod faces; +pub(crate) mod logos; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2fcb1d2..71e7d11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,20 @@ +#![recursion_limit = "256"] + +mod commands; mod connector; -mod tactic_file; +mod data; +mod fm26; +mod fm_dossier; +mod graphics; +mod mapping_lab; +mod player_face; mod visibility; use tauri_plugin_sql::{Migration, MigrationKind}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let _registered_commands = commands::registered_commands(); let migrations = vec![Migration { version: 1, description: "initial_revealed_data_schema", @@ -14,7 +23,6 @@ pub fn run() { }]; tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_sql::Builder::default() .add_migrations("sqlite:glassscout.db", migrations) @@ -25,8 +33,13 @@ pub fn run() { connector::connector_snapshot, connector::load_active_save, connector::search_indexed_players, - tactic_file::import_tactic_file, - tactic_file::tactic_file_status, + connector::indexed_players_by_ids, + connector::indexed_player_profile, + mapping_lab::mapping_lab_status, + mapping_lab::mapping_lab_capture, + mapping_lab::mapping_lab_compare, + player_face::club_logo_data, + player_face::player_face_data, visibility::filter_observations ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/mapping_lab.rs b/src-tauri/src/mapping_lab.rs new file mode 100644 index 0000000..56e4faa --- /dev/null +++ b/src-tauri/src/mapping_lab.rs @@ -0,0 +1,415 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, +}; +use tauri::Manager; + +use crate::connector::{capture_mapping_lab_player, MappingLabCaptureData}; + +const MODE_VARIABLE: &str = "GLASSSCOUT_MAPPING_MODE"; +const MAX_WINDOW_BYTES: usize = 4096; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MappingLabStatus { + enabled: bool, + read_only: bool, + maximum_window_bytes: usize, + evidence_directory: Option, + message: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EvidenceWindow { + object: String, + base_address: String, + byte_length: usize, + bytes_base64: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EvidenceSnapshot { + schema_version: u32, + captured_at: String, + label: String, + player_id: String, + player_name: String, + process_id: u32, + save_pointer: String, + entity_map_profile_id: String, + executable_sha256: String, + read_only: bool, + windows: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureResult { + success: bool, + snapshot_id: String, + evidence_file: String, + player_id: String, + player_name: String, + windows_captured: usize, + bytes_captured: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DifferenceRange { + object: String, + start_offset: usize, + end_offset: usize, + before_hex: String, + after_hex: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ComparisonResult { + success: bool, + first_snapshot_id: String, + second_snapshot_id: String, + changed_bytes: usize, + unchanged_bytes: usize, + differences: Vec, + candidate_offsets: Vec, + evidence_file: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CandidateOffset { + object: String, + offset: usize, + width: usize, + score: f64, + rationale: String, +} + +fn enabled() -> bool { + std::env::var(MODE_VARIABLE).as_deref() == Ok("1") +} + +fn lab_directory(app: &tauri::AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("The local app-data directory is unavailable: {error}"))? + .join("mapping-lab"); + fs::create_dir_all(&root).map_err(|error| { + format!("The local mapping evidence directory could not be created: {error}") + })?; + Ok(root) +} + +fn require_mode(app: &tauri::AppHandle) -> Result { + if !enabled() { + return Err(format!( + "Mapping Lab is disabled. Set {MODE_VARIABLE}=1 before launching the development build." + )); + } + lab_directory(app) +} + +fn safe_label(value: &str) -> String { + let clean: String = value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(120) + .collect(); + if clean.is_empty() { + "snapshot".to_string() + } else { + clean + } +} + +fn snapshot_path(directory: &Path, id: &str) -> Result { + let safe = safe_label(id); + if safe != id { + return Err( + "Snapshot identifiers may contain only letters, digits, hyphens and underscores." + .to_string(), + ); + } + Ok(directory.join(format!("{safe}.json"))) +} + +fn timestamp() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + +fn to_snapshot(data: MappingLabCaptureData, label: String) -> EvidenceSnapshot { + EvidenceSnapshot { + schema_version: 1, + captured_at: timestamp(), + label, + player_id: data.player_id, + player_name: data.player_name, + process_id: data.process_id, + save_pointer: data.save_pointer, + entity_map_profile_id: data.entity_map_profile_id, + executable_sha256: data.executable_sha256, + read_only: true, + windows: data + .windows + .into_iter() + .map(|window| EvidenceWindow { + object: window.object.to_string(), + base_address: window.base_address, + byte_length: window.bytes.len(), + bytes_base64: STANDARD.encode(window.bytes), + }) + .collect(), + } +} + +#[tauri::command] +pub fn mapping_lab_status(app: tauri::AppHandle) -> MappingLabStatus { + let mode_enabled = enabled(); + let directory = mode_enabled + .then(|| lab_directory(&app).ok()) + .flatten() + .map(|path| path.display().to_string()); + MappingLabStatus { + enabled: mode_enabled, + read_only: true, + maximum_window_bytes: MAX_WINDOW_BYTES, + evidence_directory: directory, + message: if mode_enabled { + "Developer mapping evidence capture is enabled. Reads are bounded and local." + .to_string() + } else { + "Developer mapping evidence capture is disabled in normal builds.".to_string() + }, + } +} + +#[tauri::command] +pub fn mapping_lab_capture( + app: tauri::AppHandle, + player_id: String, + label: String, + window_size: Option, +) -> Result { + let directory = require_mode(&app)?; + let size = window_size.unwrap_or(1024).clamp(64, MAX_WINDOW_BYTES); + let snapshot = to_snapshot( + capture_mapping_lab_player(player_id.trim(), size)?, + label.trim().to_string(), + ); + let snapshot_id = format!( + "{}-{}-{}", + snapshot.captured_at, + safe_label(&snapshot.player_id), + safe_label(&snapshot.label) + ); + let path = snapshot_path(&directory, &snapshot_id)?; + let bytes_captured = snapshot + .windows + .iter() + .map(|window| window.byte_length) + .sum(); + let windows_captured = snapshot.windows.len(); + let player_name = snapshot.player_name.clone(); + let player_id = snapshot.player_id.clone(); + let json = serde_json::to_vec_pretty(&snapshot) + .map_err(|error| format!("Mapping evidence could not be encoded: {error}"))?; + fs::write(&path, json) + .map_err(|error| format!("Mapping evidence could not be stored locally: {error}"))?; + Ok(CaptureResult { + success: true, + snapshot_id, + evidence_file: path.display().to_string(), + player_id, + player_name, + windows_captured, + bytes_captured, + }) +} + +fn load_snapshot(directory: &Path, id: &str) -> Result { + let path = snapshot_path(directory, id)?; + let bytes = + fs::read(&path).map_err(|error| format!("Snapshot {id} could not be read: {error}"))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("Snapshot {id} is not valid mapping evidence: {error}")) +} + +fn difference_ranges( + before: &EvidenceSnapshot, + after: &EvidenceSnapshot, +) -> Result<(usize, usize, Vec), String> { + if before.entity_map_profile_id != after.entity_map_profile_id + || before.executable_sha256 != after.executable_sha256 + { + return Err( + "Snapshots from different FM26 build fingerprints cannot be compared.".to_string(), + ); + } + let mut changed = 0; + let mut unchanged = 0; + let mut ranges = Vec::new(); + for first in &before.windows { + let Some(second) = after + .windows + .iter() + .find(|window| window.object == first.object) + else { + continue; + }; + let left = STANDARD + .decode(&first.bytes_base64) + .map_err(|_| "A snapshot contains invalid binary evidence.".to_string())?; + let right = STANDARD + .decode(&second.bytes_base64) + .map_err(|_| "A snapshot contains invalid binary evidence.".to_string())?; + let length = left.len().min(right.len()); + let mut offset = 0; + while offset < length { + if left[offset] == right[offset] { + unchanged += 1; + offset += 1; + continue; + } + let start = offset; + while offset < length && left[offset] != right[offset] { + changed += 1; + offset += 1; + } + let end = offset; + ranges.push(DifferenceRange { + object: first.object.clone(), + start_offset: start, + end_offset: end, + before_hex: left[start..end] + .iter() + .map(|byte| format!("{byte:02X}")) + .collect(), + after_hex: right[start..end] + .iter() + .map(|byte| format!("{byte:02X}")) + .collect(), + }); + } + } + Ok((changed, unchanged, ranges)) +} + +fn score_candidates(differences: &[DifferenceRange]) -> Vec { + differences + .iter() + .filter(|range| (1..=8).contains(&(range.end_offset - range.start_offset))) + .map(|range| { + let width = range.end_offset - range.start_offset; + let aligned = range.start_offset % width.max(1) == 0; + CandidateOffset { + object: range.object.clone(), + offset: range.start_offset, + width, + score: if aligned { 0.45 } else { 0.3 }, + rationale: "Changed in a controlled two-snapshot diff; candidate only until repeated cross-player and cross-save validation.".to_string(), + } + }) + .collect() +} + +#[tauri::command] +pub fn mapping_lab_compare( + app: tauri::AppHandle, + first_snapshot_id: String, + second_snapshot_id: String, +) -> Result { + let directory = require_mode(&app)?; + let first = load_snapshot(&directory, &first_snapshot_id)?; + let second = load_snapshot(&directory, &second_snapshot_id)?; + let (changed_bytes, unchanged_bytes, differences) = difference_ranges(&first, &second)?; + let candidate_offsets = score_candidates(&differences); + let comparison_id = format!( + "comparison-{}-{}-{}", + timestamp(), + safe_label(&first_snapshot_id), + safe_label(&second_snapshot_id) + ); + let path = snapshot_path(&directory, &comparison_id)?; + let evidence = serde_json::json!({ + "schemaVersion": 1, + "generatedAt": timestamp(), + "firstSnapshotId": first_snapshot_id, + "secondSnapshotId": second_snapshot_id, + "changedBytes": changed_bytes, + "unchangedBytes": unchanged_bytes, + "differences": &differences, + "candidateOffsets": &candidate_offsets + }); + fs::write( + &path, + serde_json::to_vec_pretty(&evidence) + .map_err(|error| format!("Comparison evidence could not be encoded: {error}"))?, + ) + .map_err(|error| format!("Comparison evidence could not be stored locally: {error}"))?; + Ok(ComparisonResult { + success: true, + first_snapshot_id, + second_snapshot_id, + changed_bytes, + unchanged_bytes, + differences, + candidate_offsets, + evidence_file: path.display().to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(bytes: &[u8]) -> EvidenceSnapshot { + EvidenceSnapshot { + schema_version: 1, + captured_at: "1".to_string(), + label: "test".to_string(), + player_id: "1".to_string(), + player_name: "Player".to_string(), + process_id: 1, + save_pointer: "0x1".to_string(), + entity_map_profile_id: "profile".to_string(), + executable_sha256: "hash".to_string(), + read_only: true, + windows: vec![EvidenceWindow { + object: "player".to_string(), + base_address: "0x10".to_string(), + byte_length: bytes.len(), + bytes_base64: STANDARD.encode(bytes), + }], + } + } + + #[test] + fn diff_groups_adjacent_changed_offsets() { + let (changed, unchanged, ranges) = + difference_ranges(&snapshot(&[1, 2, 3, 4]), &snapshot(&[1, 8, 9, 4])).unwrap(); + assert_eq!(changed, 2); + assert_eq!(unchanged, 2); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start_offset, 1); + assert_eq!(ranges[0].end_offset, 3); + let candidates = score_candidates(&ranges); + assert_eq!(candidates.len(), 1); + assert!(candidates[0].score < 0.5); + } + + #[test] + fn labels_cannot_escape_the_evidence_directory() { + assert_eq!(safe_label("../../outside"), "outside"); + assert!(snapshot_path(Path::new("lab"), "../../outside").is_err()); + } +} diff --git a/src-tauri/src/player_face.rs b/src-tauri/src/player_face.rs new file mode 100644 index 0000000..b018626 --- /dev/null +++ b/src-tauri/src/player_face.rs @@ -0,0 +1,101 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Serialize; +use std::fs; + +use crate::graphics::{ + faces::{image_mime, resolve_face_path, MAX_IMAGE_BYTES}, + logos::resolve_logo_path, +}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerFaceResult { + found: bool, + player_id: String, + data_url: Option, + source: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClubLogoResult { + found: bool, + club_id: String, + data_url: Option, +} + +#[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_IMAGE_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", + } +} + +#[tauri::command] +pub fn club_logo_data(club_id: String) -> ClubLogoResult { + let club_id = club_id.trim().to_string(); + if club_id.is_empty() || !club_id.bytes().all(|byte| byte.is_ascii_digit()) { + return ClubLogoResult { + found: false, + club_id, + data_url: None, + }; + } + if let Some(path) = resolve_logo_path(&club_id) { + if let (Some(mime), Ok(bytes)) = (image_mime(&path), fs::read(path)) { + return ClubLogoResult { + found: true, + club_id, + data_url: Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes))), + }; + } + } + ClubLogoResult { + found: false, + club_id, + data_url: None, + } +} + +fn missing(player_id: String) -> PlayerFaceResult { + PlayerFaceResult { + found: false, + player_id, + data_url: None, + source: "fallback", + } +} + +#[cfg(test)] +mod tests { + #[test] + fn player_face_command_rejects_non_numeric_ids() { + let result = super::player_face_data("../bad".to_string(), false); + assert!(!result.found); + assert_eq!(result.source, "fallback"); + } +} diff --git a/src-tauri/src/tactic_file.rs b/src-tauri/src/tactic_file.rs deleted file mode 100644 index d060f4e..0000000 --- a/src-tauri/src/tactic_file.rs +++ /dev/null @@ -1,356 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - fs::{self, File}, - io::Read, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, -}; -use tauri::{AppHandle, Manager}; - -const MAX_FMF_FILE_BYTES: u64 = 128 * 1024 * 1024; -const MAX_INSPECTION_BYTES: usize = 2 * 1024 * 1024; -const FMF_CONTAINER_MAGIC: [u8; 6] = [0x02, 0x01, b'a', b'f', b'e', b'.']; -const STATUS_FILE_NAME: &str = "current-tactic.json"; - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TacticParserStatus { - NotImported, - ImportedUnparsed, - PartiallyParsed, - Parsed, - UnsupportedFormat, - InvalidFile, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TacticFileResult { - pub success: bool, - pub file_name: Option, - pub file_size: Option, - pub imported_at: Option, - pub parser_status: TacticParserStatus, - pub parsed_formation: Option, - pub parsed_roles: Vec, - pub parsed_duties: Vec, - pub detected_format: Option, - pub compressed: Option, - pub encoded: Option, - pub warnings: Vec, - pub errors: Vec, -} - -impl TacticFileResult { - fn not_imported() -> Self { - Self { - success: false, - file_name: None, - file_size: None, - imported_at: None, - parser_status: TacticParserStatus::NotImported, - parsed_formation: None, - parsed_roles: Vec::new(), - parsed_duties: Vec::new(), - detected_format: None, - compressed: None, - encoded: None, - warnings: Vec::new(), - errors: Vec::new(), - } - } - - fn invalid(message: impl Into) -> Self { - Self { - parser_status: TacticParserStatus::InvalidFile, - errors: vec![message.into()], - ..Self::not_imported() - } - } -} - -#[tauri::command] -pub fn import_tactic_file(app: AppHandle, path: String) -> TacticFileResult { - let source = PathBuf::from(path); - if !has_fmf_extension(&source) { - return TacticFileResult::invalid("Only Football Manager .fmf tactic files are accepted."); - } - - let source = match source.canonicalize() { - Ok(value) => value, - Err(error) => { - return TacticFileResult::invalid(format!( - "The selected tactic file could not be opened: {error}" - )); - } - }; - let metadata = match source.metadata() { - Ok(value) if value.is_file() => value, - Ok(_) => return TacticFileResult::invalid("The selected path is not a file."), - Err(error) => { - return TacticFileResult::invalid(format!( - "The selected tactic file could not be inspected: {error}" - )); - } - }; - if metadata.len() == 0 { - return TacticFileResult::invalid("The selected FMF tactic file is empty."); - } - if metadata.len() > MAX_FMF_FILE_BYTES { - return TacticFileResult::invalid(format!( - "The selected FMF tactic is too large. The safe import limit is {} MB.", - MAX_FMF_FILE_BYTES / 1024 / 1024 - )); - } - - let file_name = match source.file_name().and_then(|value| value.to_str()) { - Some(value) if !value.is_empty() => value.to_string(), - _ => { - return TacticFileResult::invalid("The selected tactic file name is not valid Unicode.") - } - }; - let inspection = match read_bounded(&source, metadata.len()) { - Ok(value) => value, - Err(error) => { - return TacticFileResult::invalid(format!( - "The selected FMF tactic could not be read safely: {error}" - )); - } - }; - let mut result = inspect_fmf(&inspection, metadata.len()); - result.file_name = Some(file_name.clone()); - result.file_size = Some(metadata.len()); - result.imported_at = Some(unix_milliseconds()); - - let app_data = match app.path().app_data_dir() { - Ok(value) => value, - Err(error) => { - result.success = false; - result.errors.push(format!( - "The GlassScout app-data folder is unavailable: {error}" - )); - return result; - } - }; - let tactic_dir = app_data.join("tactics"); - if let Err(error) = fs::create_dir_all(&tactic_dir) { - result.success = false; - result.errors.push(format!( - "GlassScout could not create its tactic storage folder: {error}" - )); - return result; - } - - let digest = sha256_file(&source).unwrap_or_else(|| "unhashed".to_string()); - let stored_name = format!( - "{}-{}-{}", - result.imported_at.as_deref().unwrap_or("unknown"), - &digest[..digest.len().min(12)], - file_name - ); - let destination = tactic_dir.join(stored_name); - match fs::copy(&source, &destination) { - Ok(copied) if copied == metadata.len() => {} - Ok(copied) => { - result.success = false; - result.errors.push(format!( - "The tactic copy was incomplete: expected {} bytes, stored {copied} bytes.", - metadata.len() - )); - return result; - } - Err(error) => { - result.success = false; - result.errors.push(format!( - "GlassScout could not store the tactic in app data: {error}" - )); - return result; - } - } - - let status_path = tactic_dir.join(STATUS_FILE_NAME); - let serialized = match serde_json::to_vec_pretty(&result) { - Ok(value) => value, - Err(error) => { - result.success = false; - result.errors.push(format!( - "The tactic import status could not be serialized: {error}" - )); - return result; - } - }; - if let Err(error) = fs::write(status_path, serialized) { - result.success = false; - result.errors.push(format!( - "The tactic import status could not be stored: {error}" - )); - } - result -} - -fn has_fmf_extension(path: &Path) -> bool { - path.extension() - .and_then(|value| value.to_str()) - .is_some_and(|value| value.eq_ignore_ascii_case("fmf")) -} - -#[tauri::command] -pub fn tactic_file_status(app: AppHandle) -> TacticFileResult { - let app_data = match app.path().app_data_dir() { - Ok(value) => value, - Err(error) => { - return TacticFileResult::invalid(format!( - "The GlassScout app-data folder is unavailable: {error}" - )) - } - }; - let path = app_data.join("tactics").join(STATUS_FILE_NAME); - if !path.exists() { - return TacticFileResult::not_imported(); - } - match fs::read(&path) - .map_err(|error| error.to_string()) - .and_then(|bytes| { - serde_json::from_slice::(&bytes).map_err(|error| error.to_string()) - }) { - Ok(result) => result, - Err(error) => TacticFileResult::invalid(format!( - "The saved tactic import status is unreadable: {error}" - )), - } -} - -fn read_bounded(path: &Path, file_size: u64) -> std::io::Result> { - let mut file = File::open(path)?; - let bounded_size = file_size.min(MAX_INSPECTION_BYTES as u64) as usize; - let mut buffer = vec![0_u8; bounded_size]; - file.read_exact(&mut buffer)?; - Ok(buffer) -} - -fn inspect_fmf(bytes: &[u8], file_size: u64) -> TacticFileResult { - if bytes.is_empty() || file_size == 0 { - return TacticFileResult::invalid("The selected FMF tactic file is empty."); - } - - if bytes.starts_with(&FMF_CONTAINER_MAGIC) { - return TacticFileResult { - success: true, - file_name: None, - file_size: Some(file_size), - imported_at: None, - parser_status: TacticParserStatus::ImportedUnparsed, - parsed_formation: None, - parsed_roles: Vec::new(), - parsed_duties: Vec::new(), - detected_format: Some("sports-interactive-fmf-container".to_string()), - 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(), - ], - errors: Vec::new(), - }; - } - - let (format, compressed) = if bytes.starts_with(&[0x1f, 0x8b]) { - ("gzip-data", true) - } else if bytes.starts_with(b"PK\x03\x04") { - ("zip-data", true) - } else if bytes.len() >= 2 && bytes[0] == 0x78 && matches!(bytes[1], 0x01 | 0x5e | 0x9c | 0xda) - { - ("zlib-data", true) - } else { - ("unknown-fmf-bytes", false) - }; - TacticFileResult { - success: true, - file_name: None, - file_size: Some(file_size), - imported_at: None, - parser_status: TacticParserStatus::UnsupportedFormat, - parsed_formation: None, - parsed_roles: Vec::new(), - parsed_duties: Vec::new(), - detected_format: Some(format.to_string()), - compressed: Some(compressed), - encoded: Some(!compressed), - warnings: vec![ - "The file was stored, but its FMF payload format is not supported safely.".to_string(), - "No formation, role or duty data was inferred.".to_string(), - ], - errors: Vec::new(), - } -} - -fn sha256_file(path: &Path) -> Option { - let mut file = File::open(path).ok()?; - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = file.read(&mut buffer).ok()?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Some(format!("{:x}", hasher.finalize())) -} - -fn unix_milliseconds() -> String { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis().to_string()) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn recognized_fmf_container_is_imported_but_never_guessed() { - let mut bytes = FMF_CONTAINER_MAGIC.to_vec(); - bytes.extend_from_slice(&[0x08, 0, 0, 0xef, 0x02, 0, 0]); - let result = inspect_fmf(&bytes, bytes.len() as u64); - assert!(result.success); - assert!(matches!( - result.parser_status, - TacticParserStatus::ImportedUnparsed - )); - assert!(result.parsed_formation.is_none()); - assert!(result.parsed_roles.is_empty()); - assert!(result.parsed_duties.is_empty()); - } - - #[test] - fn unknown_fmf_bytes_do_not_crash_or_create_tactic_data() { - let result = inspect_fmf(&[0xde, 0xad, 0xbe, 0xef, 0x00, 0xff], 6); - assert!(result.success); - assert!(matches!( - result.parser_status, - TacticParserStatus::UnsupportedFormat - )); - assert!(result.parsed_formation.is_none()); - } - - #[test] - fn empty_input_is_invalid() { - let result = inspect_fmf(&[], 0); - assert!(!result.success); - assert!(matches!( - result.parser_status, - TacticParserStatus::InvalidFile - )); - } - - #[test] - fn only_fmf_extensions_are_accepted() { - assert!(has_fmf_extension(Path::new("Madla.fmf"))); - assert!(has_fmf_extension(Path::new("Madla.FMF"))); - assert!(!has_fmf_extension(Path::new("Madla.csv"))); - assert!(!has_fmf_extension(Path::new("Madla"))); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8dc3f11..92254f9 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.17", "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 b33d94a..d2389c0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -676,7 +676,7 @@ h1, h2, h3, [data-slot="card-title"] { font-family: "Inter Variable", Inter, "Se .squad-live-table-rich{overflow-x:auto}.squad-live-table-rich>header,.squad-live-table-rich .squad-player-row-rich{grid-template-columns:minmax(170px,1.2fr) 125px 135px 70px 125px 130px 100px 100px minmax(210px,1.3fr) 82px;min-width:1280px}.squad-player-row-rich>span:nth-last-child(2){font-size:7px;line-height:1.3}.knowledge-fully_known{color:var(--mint)}.knowledge-partly_known{color:#49798a}.knowledge-needs_scouting,.knowledge-missing_data{color:#a96f18} .squad-live-table-verified>header,.squad-live-table-verified .squad-player-row-verified{grid-template-columns:minmax(220px,1.3fr) minmax(170px,1fr) minmax(150px,.8fr) minmax(130px,.7fr) 90px}.squad-summary-grid-verified{grid-template-columns:repeat(3,1fr)} .player-row-actions{display:flex;gap:5px}.player-profile-hero{margin-top:12px}.profile-knowledge{text-transform:capitalize;font-size:10px;font-weight:700}.player-profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.profile-wide{grid-column:1/-1}.profile-detail-list{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}.profile-detail-list span{display:flex;flex-direction:column;gap:4px;padding:10px;border:1px solid var(--border);border-radius:9px;background:rgba(248,251,251,.72)}.profile-detail-list small,.profile-evidence-grid small{color:var(--slate);font-size:8px;text-transform:uppercase;letter-spacing:.04em}.profile-detail-list strong{font-size:11px}.profile-decision{padding:16px}.profile-decision>strong{font-size:13px;color:var(--navy)}.profile-decision p{color:var(--slate);font-size:10px;line-height:1.5}.profile-decision span{display:block;margin-top:10px;padding:10px;border-radius:8px;background:#eaf6f2;color:#126b61;font-size:10px;font-weight:700}.profile-evidence-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:14px}.profile-evidence-grid>div{padding:12px;border:1px solid var(--border);border-radius:9px}.profile-evidence-grid span{display:flex;justify-content:space-between;margin-top:8px;font-size:10px;text-transform:capitalize}.profile-evidence-grid p{margin:8px 0 0;color:var(--slate);font-size:9px;line-height:1.45} -.role-dna-table{border:1px solid var(--border);border-radius:13px;overflow:hidden;background:rgba(255,255,255,.74);box-shadow:var(--panel-shadow)}.role-dna-table>header,.role-dna-table>article{display:grid;grid-template-columns:minmax(160px,1fr) 120px 150px 160px 80px minmax(220px,1.4fr) minmax(220px,1.4fr);align-items:center;gap:10px;padding:0 13px}.role-dna-table>header{height:34px;background:#edf3f4;color:var(--slate);font-size:7px;text-transform:uppercase}.role-dna-table>article{min-height:58px;border-top:1px solid var(--border);font-size:8px}.role-dna-table>article span{color:#53677e;line-height:1.35} +.role-dna-table{border:1px solid var(--border);border-radius:13px;overflow:hidden;background:rgba(255,255,255,.74);box-shadow:var(--panel-shadow)}.role-dna-table>header,.role-dna-table>article{display:grid;grid-template-columns:minmax(150px,1fr) 105px 130px minmax(130px,1fr) minmax(130px,1fr) 72px minmax(180px,1.25fr) minmax(240px,1.4fr);align-items:center;gap:10px;padding:0 13px}.role-dna-table>header{height:34px;background:#edf3f4;color:var(--slate);font-size:7px;text-transform:uppercase}.role-dna-table>article{min-height:66px;border-top:1px solid var(--border);font-size:8px}.role-dna-table>article span{color:#53677e;line-height:1.35} /* Reference-matched 0.1.4 product shell */ :root{ @@ -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} @@ -757,6 +763,7 @@ body{background:#f8fbfc;color:var(--navy);font-size:14px} .dossier-layout{display:grid;grid-template-columns:minmax(660px,2.35fr) minmax(300px,.82fr);gap:14px;padding-top:14px}.dossier-main-column,.dossier-side-column{display:flex;flex-direction:column;gap:10px}.dossier-panel{border:1px solid var(--border);border-radius:8px;background:#fff;overflow:hidden}.dossier-panel>header{display:flex;align-items:center;gap:9px;height:42px;padding:0 13px}.dossier-panel>header svg{width:15px;color:#324d70}.dossier-panel>header h2{margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.02em}.dossier-panel>header>span{margin-left:auto;color:var(--slate);font-size:8px} .scout-summary-panel>p{margin:0;padding:0 13px 13px;font-size:11px;line-height:1.55}.summary-columns{display:grid;grid-template-columns:1fr 1fr 1.25fr;border-top:1px solid var(--border)}.summary-columns>div{min-height:96px;padding:12px 14px;border-left:1px solid var(--border)}.summary-columns>div:first-child{border-left:0}.summary-columns h3{display:flex;align-items:center;gap:7px;margin:0 0 9px;font-size:10px;text-transform:uppercase}.summary-columns h3 svg{width:17px;color:var(--mint)}.summary-columns>div:nth-child(2) h3 svg{color:var(--amber)}.summary-columns span{color:#445b7a;font-size:10px;line-height:1.5} .role-fit-layout{display:grid;grid-template-columns:280px 1fr 130px;align-items:center;gap:20px;padding:0 13px 15px}.role-fit-metrics{display:grid;grid-template-columns:1fr 1fr;gap:12px}.role-fit-metrics span{display:flex;flex-direction:column;gap:7px}.role-fit-metrics span:first-child{grid-column:1/-1}.role-fit-metrics small{color:var(--slate);font-size:8px;text-transform:uppercase}.role-fit-metrics strong{font-size:15px}.mini-role-map{position:relative;height:120px;border:1px solid #9cadc1}.mini-role-map .mini-half{position:absolute;left:50%;top:0;bottom:0;width:1px;background:#9cadc1}.mini-role-map .mini-centre{position:absolute;left:50%;top:50%;width:44px;height:44px;border:1px solid #9cadc1;border-radius:50%;transform:translate(-50%,-50%)}.mini-box{position:absolute;top:25%;width:18%;height:50%;border:1px solid #9cadc1}.mini-box.left{left:-1px}.mini-box.right{right:-1px}.mini-role-map>i{position:absolute;left:50%;top:50%;padding:4px 7px;background:#fff;color:var(--slate);font-size:8px;font-style:normal;transform:translate(-50%,-50%);white-space:nowrap}.fit-legend{display:flex;flex-direction:column;gap:10px}.fit-legend span{display:flex;align-items:center;gap:8px;font-size:9px}.fit-legend i{width:14px;height:14px;border-radius:2px}.fit-legend i[data-tone=strong]{background:#39b9a2}.fit-legend i[data-tone=good]{background:#aae2d7}.fit-legend i[data-tone=suitable]{background:#dfe5ea}.fit-legend i[data-tone=low]{border:1px solid #dfe5ea} +.role-fit-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));gap:9px;padding:0 13px 14px}.role-fit-cards article{display:grid;grid-template-columns:42px 1fr;gap:9px;align-items:center;min-height:62px;padding:10px;border:1px solid var(--border);border-radius:8px;background:#f8fbfb}.role-fit-cards article>span{display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:conic-gradient(var(--mint) var(--role-score,0%),#e7edf2 0);border:1px solid #d8e3eb}.role-fit-cards strong{font-size:10px}.role-fit-cards small{display:block;margin-top:3px;color:var(--slate);font-size:8px;line-height:1.35}.role-fit-cards article>span small{margin-top:0;color:var(--mint);font-weight:800}.role-fit-empty{margin:0;padding:0 13px 14px;color:var(--slate);font-size:9px} .attribute-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:0 13px 14px}.attribute-column{padding:0 18px;border-left:1px solid var(--border)}.attribute-column:first-child{padding-left:0;border-left:0}.attribute-column h3{margin:0 0 8px;font-size:10px;text-transform:uppercase}.attribute-column span{display:flex;justify-content:space-between;gap:10px;padding:3px 0}.attribute-column small{font-size:9px}.attribute-column strong{font-size:9px} .knowledge-overview{display:grid;grid-template-columns:95px 1fr;align-items:center;gap:10px;padding:0 13px 12px}.knowledge-donut{--known:0%;width:80px;height:80px;border-radius:50%;background:radial-gradient(circle,#fff 53%,transparent 55%),conic-gradient(#2db79e var(--known),#e5eaee 0)}.knowledge-overview>div{display:flex;flex-direction:column;gap:9px}.knowledge-overview>div span{display:grid;grid-template-columns:8px 1fr auto;align-items:center;gap:7px;font-size:9px}.knowledge-overview i{width:8px;height:8px;border-radius:50%}.knowledge-overview i[data-tone=known]{background:#2db79e}.knowledge-overview i[data-tone=estimated]{background:#a5ded3}.knowledge-overview i[data-tone=unknown]{background:#dfe5ea}.knowledge-panel dl{margin:0;padding:10px 13px;border-top:1px solid var(--border)}.knowledge-panel dl div{display:flex;justify-content:space-between;padding:3px 0;font-size:9px}.knowledge-panel dt{color:var(--slate)}.knowledge-panel dd{margin:0;font-weight:650} .compact-dossier-panel>button{display:grid;grid-template-columns:1fr auto 14px;align-items:center;width:100%;height:37px;padding:0 12px;border:0;border-top:1px solid var(--border);background:#fff;text-align:left}.compact-dossier-panel>button span{display:flex;align-items:center;gap:8px;font-size:9px}.compact-dossier-panel>button i{width:9px;height:9px;border-radius:50%;background:#e7a52b}.compact-dossier-panel>button strong{font-size:9px}.compact-dossier-panel>button svg{width:13px;color:var(--slate)} @@ -764,6 +771,8 @@ body{background:#f8fbfc;color:var(--navy);font-size:14px} .dossier-locked-state{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:500px;text-align:center}.dossier-locked-state svg{width:34px;color:var(--amber)}.dossier-locked-state h2{margin:12px 0 6px;font-size:18px}.dossier-locked-state p{color:var(--slate);font-size:11px} .settings-list article{padding:18px}.settings-list strong{font-size:12px}.settings-list span{font-size:10px}.settings-list b{font-size:10px}.advanced-diagnostics summary span{font-size:12px}.advanced-diagnostics>p{font-size:10px}.advanced-diagnostics dt{font-size:9px}.advanced-diagnostics dd{font-size:10px} +.mapping-coverage{border-top:1px solid var(--border)}.mapping-coverage>header,.mapping-coverage>article{display:grid;grid-template-columns:minmax(150px,1fr) repeat(3,110px);align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--border)}.mapping-coverage>header{display:flex;justify-content:space-between;background:#f7fafb}.mapping-coverage>header span,.mapping-coverage>article span{color:var(--slate);font-size:9px}.mapping-coverage>article strong{text-transform:capitalize;font-size:10px}.mapping-coverage .coverage-good{color:#078c7c}.mapping-coverage .coverage-watch{color:#a87319} +.mapping-lab-panel{padding:16px;border-top:1px solid var(--border);background:#f7fbfa}.mapping-lab-panel>header{display:flex;justify-content:space-between}.mapping-lab-panel>header span,.mapping-lab-panel>p,.mapping-lab-panel>small{color:var(--slate);font-size:9px}.mapping-lab-panel>div{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin:10px 0}.mapping-lab-panel .mapping-lab-error{display:block;color:#a04747} .load-failure{margin-top:18px!important;padding:10px 12px;border:1px solid #e8b25a;border-radius:8px;background:#fff8ea;color:#8a5c15!important;font-size:10px!important} @media (max-width:1280px){ @@ -773,3 +782,1419 @@ 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} + +/* 0.1.6 live-database and dossier refinements */ +.scout-room-toolbar{display:grid;grid-template-columns:minmax(320px,2fr) minmax(220px,1fr) 130px 140px;gap:10px;margin:14px 0;padding:12px;border:1px solid var(--border);border-radius:10px;background:#fff} +.interest-switch{display:flex;align-items:center;gap:9px;padding:0 12px;border:1px solid var(--border);border-radius:8px;color:var(--navy);font-size:9px;font-weight:700} +.interest-switch>button{position:relative;width:38px;height:20px;padding:0;border:1px solid #c7d4dc;border-radius:999px;background:#e7edf0} +.interest-switch>button i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .18s ease} +.interest-switch>button.on{border-color:#19a98f;background:#19a98f}.interest-switch>button.on i{transform:translateX(18px)} +.interest-switch small{color:var(--slate)} +.scout-room-board>header,.scout-room-board>article{grid-template-columns:minmax(260px,1.55fr) minmax(175px,1fr) 110px 125px 155px 150px 60px;min-width:1160px} +.scout-room-board>article{min-height:92px;cursor:pointer;transition:background .15s ease}.scout-room-board>article:hover{background:#f7fbfa} +.scout-room-board>article.indexed-player-row{cursor:default} +.sort-heading{display:flex;align-items:center;gap:4px;padding:0;border:0;background:transparent;color:inherit;font:inherit;text-transform:inherit}.sort-heading svg{width:11px}.sort-heading.active{color:var(--primary)}.sort-heading small{font-size:6px} +.target-identity>.player-face{width:52px;height:58px;border-radius:9px}.target-identity>.player-face img{object-position:center top} +.target-club{display:flex;align-items:center;gap:5px}.target-club .club-logo{width:18px;height:18px;border-radius:4px} +.form-market-cell{gap:5px!important}.form-line{display:block;width:100%;height:5px;overflow:hidden;border-radius:999px;background:#e3ebef}.form-line i{display:block;height:100%;border-radius:inherit;background:#23b596} +.shortlist-active{border-color:#e2b33e!important;background:#fff9e8!important;color:#d89b00!important} +.global-search-results{position:absolute;z-index:30;top:78px;left:50%;width:min(620px,70vw);max-height:460px;overflow:auto;transform:translateX(-50%);border:1px solid var(--border);border-radius:10px;background:#fff;box-shadow:0 18px 50px rgba(20,44,67,.18)} +.global-search-results header{display:flex;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--border)}.global-search-results header button{border:0;background:transparent;color:var(--primary)} +.global-search-results>button{display:grid;grid-template-columns:52px 1fr auto;width:100%;padding:11px 14px;border:0;border-bottom:1px solid var(--border);background:#fff;text-align:left}.global-search-results>button:hover{background:#f5faf9}.global-search-results>button span{color:var(--primary);font-size:8px;text-transform:uppercase}.global-search-results>button strong{font-size:10px}.global-search-results>button small{color:var(--slate);font-size:8px}.global-search-results>p{padding:20px;color:var(--slate);font-size:10px} +.player-club-fact{display:flex;align-items:center;gap:8px;min-height:58px;padding:8px 12px;border:0;border-left:1px solid var(--border);background:transparent;text-align:left}.player-club-fact>span:last-child{display:flex;flex-direction:column;gap:5px}.player-club-fact:not(:disabled):hover{background:#f5faf9} +.club-logo{display:grid;place-items:center;overflow:hidden;border-radius:7px;background:#f0f6f7;color:var(--primary)}.club-logo img{width:100%;height:100%;object-fit:contain}.club-logo svg{width:55%;height:55%}.club-logo-sm{width:32px;height:32px}.club-logo-md{width:54px;height:54px}.club-logo-lg{width:76px;height:76px} +.club-profile-header{display:flex;align-items:center;gap:16px;padding:18px;border:1px solid var(--border);border-radius:12px;background:#fff}.club-profile-header h1{margin:0;font-size:26px}.club-profile-header p{margin:4px 0 0;color:var(--slate);font-size:10px}.club-profile-header>span:last-child{display:flex;align-items:center;gap:7px;margin-left:auto;color:var(--slate);font-size:10px}.club-profile-header>span svg{width:17px} +.club-squad-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}.club-squad-grid>button{display:flex;align-items:center;gap:11px;padding:12px;border:1px solid var(--border);border-radius:9px;background:#fff;text-align:left}.club-squad-grid>button:hover{border-color:#8fd7c8;background:#f7fbfa}.club-squad-grid>button>span{display:flex;flex-direction:column;gap:4px}.club-squad-grid strong{font-size:10px}.club-squad-grid small,.club-squad-grid b{color:var(--slate);font-size:8px} +.attribute-grid-four{grid-template-columns:repeat(4,1fr)}.tab-evidence-panel{margin:16px 0}.role-phase-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;padding:0 14px 14px}.role-phase-grid article,.performance-grid article{padding:14px;border:1px solid var(--border);border-radius:8px;background:#f8fbfb}.role-phase-grid small,.performance-grid small{color:var(--slate);font-size:8px;text-transform:uppercase}.role-phase-grid strong,.performance-grid strong{display:block;margin-top:7px;font-size:13px}.role-phase-grid p{color:var(--slate);font-size:9px;line-height:1.5} +.attribute-grid-five{grid-template-columns:repeat(5,minmax(0,1fr))} +.favorites-table>header,.favorites-table>article{grid-template-columns:55px minmax(260px,1.45fr) 170px 145px minmax(210px,1fr) minmax(220px,1fr) 55px;min-width:1200px}.favorites-table>article{min-height:96px}.favorite-player,.favorite-knowledge{display:flex!important;flex-direction:row!important;align-items:center;gap:10px}.favorite-player>span,.favorite-knowledge>span:last-child{display:flex;min-width:0;flex-direction:column;gap:4px}.favorite-player .player-face{width:52px;height:58px;border-radius:9px}.favorite-knowledge .confidence-ring{flex:0 0 auto} +.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-command-center{ + margin:0 0 13px; + padding:16px; + border:1px solid var(--scout-border); + border-radius:12px; + background:#fff; +} +.scout-command-center>header{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:18px; + margin-bottom:13px; +} +.scout-command-center h2{ + margin:4px 0 5px; + color:var(--scout-navy); + font-size:16px; + letter-spacing:-.025em; +} +.scout-command-center p{ + max-width:760px; + margin:0; + color:var(--scout-muted); + font-size:10px; + line-height:1.55; +} +.command-center-stats{ + display:flex; + align-items:center; + gap:8px; + white-space:nowrap; +} +.command-center-stats span{ + display:inline-flex; + align-items:center; + gap:6px; + min-height:32px; + padding:0 10px; + border:1px solid #e2edf3; + border-radius:999px; + background:#f8fbfd; + color:#526a86; + font-size:9px; + font-weight:760; +} +.command-center-stats strong{color:var(--scout-navy)} +.tactic-need-grid{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(215px,1fr)); + gap:9px; +} +.tactic-need-card{ + display:flex; + flex-direction:column; + gap:10px; + min-height:150px; + padding:13px; + border:1px solid #e2edf3; + border-radius:10px; + background:#fbfdfe; +} +.tactic-need-card.good{border-color:#d7ebe6;background:#fbfffe} +.tactic-need-card.watch{border-color:#f4dfad;background:#fffdf8} +.tactic-need-card.need{border-color:#f0c8be;background:#fffbfa} +.tactic-need-card>div:first-child{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:12px; +} +.tactic-need-card strong{color:var(--scout-navy);font-size:13px} +.tactic-need-card small{color:#63748c;font-size:8px;font-weight:700} +.tactic-need-card dl{ + display:grid; + grid-template-columns:repeat(3,1fr); + gap:7px; + margin:0; +} +.tactic-need-card dl div{ + min-width:0; + padding:8px; + border-radius:8px; + background:#fff; + border:1px solid #e7eff4; +} +.tactic-need-card dt{ + margin-bottom:4px; + color:#7a8da3; + font-size:7px; + font-weight:800; + text-transform:uppercase; +} +.tactic-need-card dd{ + margin:0; + overflow:hidden; + color:#10244b; + font-size:9px; + font-weight:800; + text-overflow:ellipsis; + white-space:nowrap; +} +.need-target{ + margin-top:auto; + padding:10px; + border:1px solid #cfe8e1; + border-radius:9px; + background:#f3fbf8; + color:#0d6f60; + text-align:left; +} +.need-target span, +.need-target small{ + display:block; + color:#52766f; + font-size:8px; + font-weight:800; +} +.need-target b{ + display:block; + margin:3px 0; + color:#0b3f39; + font-size:10px; +} +.tactic-need-card>p{ + margin:auto 0 0; + color:#64768f; + font-size:9px; + line-height:1.45; +} +.tactic-need-empty{ + display:flex; + align-items:center; + gap:10px; + padding:14px; + border:1px dashed #c9dce7; + border-radius:10px; + background:#f9fcfd; + color:#63748c; +} +.tactic-need-empty svg{width:18px;color:var(--scout-green)} +.tactic-need-empty strong{color:var(--scout-navy);font-size:11px} +.tactic-need-empty span{font-size:9px} +.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:76px; + 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:48px!important; + height:52px!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{background:#dcf8eb;color:#007e61} +.interest-pill.slightly-interested, +.interest-pill.only-loan-possible{background:#e8f1ff;color:#2d67bb} +.interest-pill.doubtful, +.interest-pill.only-if-promoted, +.interest-pill.only-if-wages-are-improved, +.interest-pill.only-if-guaranteed-playing-time{background:#fff0dc;color:#a06000} +.interest-pill.not-interested{background:#ffe8e4;color:#a43e30} +.interest-pill.unknown{background:#edf3f7;color:#61738a} +.interest-stack{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:4px; +} +.availability-pill{ + display:inline-flex; + align-items:center; + max-width:92px; + padding:3px 7px; + border:1px solid #e1eaf0; + border-radius:999px; + background:#fff; + color:#65768d; + font-size:7px; + font-weight:800; +} +.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; +} + +/* 0.1.13 — two-floor top command bar with real history controls */ +.app-sidebar, +.app-canvas:has(.scout-room-reference) .app-sidebar{ + border-bottom:0; + box-shadow:none; +} +.manager-avatar, +.user-avatar{ + display:none!important; +} +.sidebar-footer, +.app-canvas:has(.scout-room-reference) .sidebar-footer{ + gap:10px; + color:#405876; +} +.sidebar-footer>span, +.app-canvas:has(.scout-room-reference) .sidebar-footer>span{ + display:flex; + flex-direction:column; + gap:2px; +} +.sidebar-footer strong{font-size:11px} +.sidebar-footer small{font-size:9px;color:#6d7e94} +.sidebar-footer svg{width:16px;height:16px;color:#6d7e94} +.topbar, +.topbar-dashboard{ + top:68px; + display:grid; + grid-template-columns:minmax(272px,365px) minmax(420px,680px) minmax(285px,1fr); + align-items:center; + gap:20px; + height:76px; + padding:0 28px; + border-bottom:0; + background:rgba(251,253,253,.96); + box-shadow:none; + backdrop-filter:blur(16px); +} +.topbar-left-cluster{ + display:flex; + align-items:center; + min-width:0; + gap:14px; +} +.topbar-history-controls{ + display:flex; + align-items:center; + gap:3px; + flex:0 0 auto; + height:38px; + padding:3px; + border:1px solid #d8e3eb; + border-radius:11px; + background:#fff; +} +.topbar-history-controls button{ + width:30px; + height:30px; + border-radius:8px; +} +.topbar-history-controls button svg{ + width:16px; + height:16px; +} +.topbar-history-controls button:disabled{ + opacity:.32; + cursor:not-allowed; +} +.dashboard-greeting, +.screen-context{ + display:flex; + flex-direction:column; + min-width:0; + gap:4px; +} +.screen-context strong, +.dashboard-greeting strong{ + overflow:hidden; + color:#0b1934; + font-size:16px; + letter-spacing:-.02em; + text-overflow:ellipsis; + white-space:nowrap; +} +.screen-context span, +.dashboard-greeting span{ + display:flex; + align-items:center; + min-width:0; + gap:9px; + overflow:hidden; + color:#58708e; + font-size:12px; + text-overflow:ellipsis; + white-space:nowrap; +} +.screen-context i, +.dashboard-greeting i, +.sync-badge i{ + flex:0 0 auto; + width:4px; + height:4px; + border-radius:999px; + background:#1eb9a2; +} +.topbar .search-wrap, +.topbar-dashboard .search-wrap{ + height:46px; + max-width:680px; +} +.topbar .search-wrap input, +.topbar-dashboard .search-wrap input{ + height:46px; + border-color:#d3dfe8; + box-shadow:none; +} +.topbar-actions{ + min-width:0; +} +.topbar-actions .sync-badge{ + height:40px; + box-shadow:none; +} +.topbar-actions>button{ + width:40px; + height:40px; +} +.screen-slot{ + min-height:calc(100vh - 144px); +} +.global-search-results{ + top:82px; +} + +/* 0.1.15 — compact unified sticky header shell */ +.app-canvas, +.app-canvas:has(.scout-room-reference){ + grid-template-rows:112px minmax(0,1fr); +} +.app-top-shell{ + position:fixed; + top:0; + left:0; + right:0; + z-index:60; + grid-row:1; + grid-column:1; + display:grid; + grid-template-rows:56px 56px; + min-width:0; + background:rgba(251,253,253,.97); + border-bottom:1px solid rgba(211,224,234,.72); + box-shadow:0 10px 34px rgba(16,40,65,.05); + backdrop-filter:blur(18px); +} +.app-top-shell .app-sidebar, +.app-canvas:has(.scout-room-reference) .app-top-shell .app-sidebar{ + position:relative; + top:auto; + z-index:2; + grid-row:1; + height:56px; + padding:0 28px; + background:transparent; + backdrop-filter:none; +} +.app-top-shell .topbar, +.app-top-shell .topbar-dashboard{ + position:relative; + top:auto; + z-index:1; + grid-row:2; + height:56px; + padding:0 28px; + grid-template-columns:minmax(238px,330px) minmax(420px,680px) minmax(260px,1fr); + gap:18px; + background:transparent; + backdrop-filter:none; +} +.app-main{ + grid-row:2; +} +.app-top-shell .brand-mark, +.app-canvas:has(.scout-room-reference) .app-top-shell .brand-mark{ + width:31px; + height:31px; +} +.app-top-shell .brand-mark:before, +.app-canvas:has(.scout-room-reference) .app-top-shell .brand-mark:before{width:40px} +.app-top-shell .brand-mark:after, +.app-canvas:has(.scout-room-reference) .app-top-shell .brand-mark:after{height:40px} +.app-top-shell .brand-copy strong{font-size:14px} +.app-top-shell .brand-copy small{font-size:10px} +.app-top-shell .nav-item, +.app-canvas:has(.scout-room-reference) .app-top-shell .nav-item{ + height:34px; + padding:0 12px; + border-radius:9px; + font-size:11px; +} +.app-top-shell .nav-item svg, +.app-canvas:has(.scout-room-reference) .app-top-shell .nav-item svg{ + width:16px; + height:16px; +} +.app-top-shell .sidebar-footer strong{font-size:10px} +.app-top-shell .sidebar-footer small{font-size:8px} +.app-top-shell .topbar-left-cluster{ + gap:12px; +} +.app-top-shell .topbar-history-controls{ + height:34px; + border-radius:10px; +} +.app-top-shell .topbar-history-controls button{ + width:27px; + height:27px; +} +.app-top-shell .screen-context, +.app-top-shell .dashboard-greeting{ + gap:3px; +} +.app-top-shell .screen-context strong, +.app-top-shell .dashboard-greeting strong{ + font-size:14px; +} +.app-top-shell .screen-context span, +.app-top-shell .dashboard-greeting span{ + font-size:11px; +} +.app-top-shell .search-wrap, +.app-top-shell .topbar-dashboard .search-wrap{ + height:40px; +} +.app-top-shell .search-wrap input, +.app-top-shell .topbar-dashboard .search-wrap input{ + height:40px; + padding-left:44px; + border-radius:10px; +} +.app-top-shell .search-wrap>svg{ + width:16px; +} +.app-top-shell .keyboard-hint{ + padding:4px 7px; +} +.app-top-shell .sync-badge{ + height:36px; + padding-inline:12px; +} +.app-top-shell .topbar-actions{ + gap:10px; +} +.app-top-shell .topbar-actions>button{ + width:36px; + height:36px; +} +.app-top-shell .global-search-results{ + position:absolute; + top:102px; + left:50%; +} +.screen-slot{ + min-height:calc(100vh - 112px); +} +.screen{ + padding-top:16px; +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 4510007..76882cb 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,20 @@ import { cn } from "@/lib/utils"; export type Screen = | "Dashboard" - | "Squad Planner" + | "Squad" | "Tactical Board" | "Scout Room" - | "Recruitment" - | "Players" | "Shortlist" - | "Reports" | "Player Profile" + | "Club 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 +60,6 @@ export function AppSidebar({
-
- -
- Revealed data only - Hidden values blocked before scoring. -
-
- TB - Recruitment deskLive FM26 + Scouting deskLive FM26
diff --git a/src/components/club-logo.tsx b/src/components/club-logo.tsx new file mode 100644 index 0000000..964555c --- /dev/null +++ b/src/components/club-logo.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Shield } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; + +type ClubLogoResult = { found: boolean; clubId: string; dataUrl: string | null }; +const cache = new Map(); + +export function ClubLogo({ clubId, name, size = "md" }: { clubId: string; name: string; size?: "sm" | "md" | "lg" }) { + const [source, setSource] = useState(() => cache.get(clubId)); + useEffect(() => { + if (cache.has(clubId) || !("__TAURI_INTERNALS__" in window)) return; + let active = true; + invoke("club_logo_data", { clubId }).then((result) => { + const next = result.found ? result.dataUrl : null; + cache.set(clubId, next); + if (active) setSource(next); + }).catch(() => { cache.set(clubId, null); if (active) setSource(null); }); + return () => { active = false; }; + }, [clubId]); + return {source ? {`${name} : }; +} diff --git a/src/components/club-profile-screen.tsx b/src/components/club-profile-screen.tsx new file mode 100644 index 0000000..3a5f56e --- /dev/null +++ b/src/components/club-profile-screen.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ArrowLeft, UsersRound } from "lucide-react"; +import type { LiveFootballSnapshot } from "@/domain/adapters"; +import { Button } from "@/components/ui/button"; +import { ClubLogo } from "@/components/club-logo"; +import { PlayerFace } from "@/components/player-face"; + +export function ClubProfileScreen({ clubId, snapshot, onBack, onOpenPlayer }: { + clubId: string | null; + snapshot: LiveFootballSnapshot; + onBack: () => void; + onOpenPlayer: (id: string) => void; +}) { + const club = snapshot.clubs.find((item) => item.id === clubId); + if (!club) return

Club unavailable

This team is not mapped in the current live snapshot.

; + const squad = snapshot.players.filter((player) => player.clubId === club.id); + return ( +
+

{club.name}

{club.nation ?? "Nation unknown"} · {club.league ?? "Competition unknown"}

{squad.length} mapped players
+
+ {squad.map((player) => )} +
+
+ ); +} diff --git a/src/components/dashboard-screen.tsx b/src/components/dashboard-screen.tsx index d43e41e..1b532d9 100644 --- a/src/components/dashboard-screen.tsx +++ b/src/components/dashboard-screen.tsx @@ -89,18 +89,20 @@ export function DashboardScreen({ { icon: snapshot.tactic ? ClipboardCheck : FileWarning, title: snapshot.tactic ? `${snapshot.tactic.formation} tactic ready` : "Tactical decoding needs attention", - detail: snapshot.tacticFileName - ? `${snapshot.tacticFileName} is stored safely, but this FMF format is not decoded.` - : "Import the active FMF tactic to enable role and tactical-fit analysis.", - action: snapshot.tactic ? "Open board" : "Import tactic", + detail: snapshot.status.liveMemoryTacticRead === "object_detected_unmapped" + ? "The active live tactic object is detected; selected slots did not validate on this read." + : snapshot.tactic + ? "Formation and selected XI are mapped from the active FM26 tactic. Role codes are pending." + : "Open FM26 and select the active tactic so GlassScout can inspect the live object.", + action: "Open board", screen: "Tactical Board" as Screen, }, { icon: ShieldCheck, - title: `${squad.length} managed-club players visibility-safe`, - detail: "Names and positions are live; unsupported fields remain Unknown.", + 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 Planner" as Screen, + screen: "Squad" as Screen, }, ]; @@ -110,19 +112,19 @@ export function DashboardScreen({
-

Recruitment pulse

{externalVisible.length} live targets
+

Scout Room pulse

{externalVisible.length} live targets
- 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 5921cfb..c875b4d 100644 --- a/src/components/favorited-players-screen.tsx +++ b/src/components/favorited-players-screen.tsx @@ -1,15 +1,29 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Search, Star, Trash2, Users } from "lucide-react"; -import type { LiveFootballSnapshot } from "@/domain/adapters"; +import { indexedPlayersByIds, type IndexedPlayerSearchResult, type LiveFootballSnapshot, type LivePlayer } from "@/domain/adapters"; import { resolveFavorites, type FavoriteRecord } from "@/domain/live-data"; import { LiveDataState } from "@/components/live-data-state"; +import { PlayerFace } from "@/components/player-face"; +import { ConfidenceRing } from "@/components/confidence-ring"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; type FavoriteSort = "name" | "age" | "roleFit" | "tacticalFit"; +function indexedFavorite(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, + }; +} + export function FavoritedPlayersScreen({ snapshot, favorites, @@ -17,6 +31,7 @@ export function FavoritedPlayersScreen({ onRefresh, onToggleFavorite, onUpdateNote, + onOpenPlayer, }: { snapshot: LiveFootballSnapshot; favorites: FavoriteRecord[]; @@ -24,11 +39,26 @@ 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"); const [compareIds, setCompareIds] = useState([]); - const resolved = useMemo(() => resolveFavorites(favorites, snapshot.players), [favorites, snapshot.players]); + const [indexedFavorites, setIndexedFavorites] = useState([]); + + useEffect(() => { + if (snapshot.status.state !== "connected") return; + indexedPlayersByIds(favorites.map((favorite) => favorite.playerId)) + .then(setIndexedFavorites) + .catch(() => setIndexedFavorites([])); + }, [favorites, snapshot.status.lastSync, snapshot.status.state]); + + const allFavorites = useMemo(() => { + const merged = new Map(snapshot.players.map((player) => [player.id, player])); + for (const result of indexedFavorites) if (!merged.has(result.id)) merged.set(result.id, indexedFavorite(result)); + return [...merged.values()]; + }, [indexedFavorites, snapshot.players]); + const resolved = useMemo(() => resolveFavorites(favorites, allFavorites), [allFavorites, favorites]); const visible = useMemo(() => resolved .filter(({ player }) => player.name.toLowerCase().includes(query.trim().toLowerCase())) .toSorted((a, b) => { @@ -43,46 +73,47 @@ export function FavoritedPlayersScreen({ : current.length < 2 ? [...current, playerId] : [current[1], playerId]); }; - if (snapshot.status.state !== "connected" || snapshot.players.length === 0) { - return
; + if (snapshot.status.state !== "connected") { + return
; } return (
-

Favorites

A local GlassScout list resolved against the latest live FM26 players.

-
{resolved.length} local favorites
+

Shortlist

Live indexed identities with club-visible evidence and local decision notes.

+
{resolved.length} shortlisted
-
FM26’s own shortlist collection is not mapped safely for this exact build. These favorites stay local and never create or preserve player data.
+
FM26’s own shortlist relation is not mapped safely for this exact build. Local entries resolve against the current live player index; unknown evidence stays unknown.
-
setQuery(event.target.value)} />
+
setQuery(event.target.value)} />
Select up to two players to compare
{visible.length ? (
-
ComparePlayerLatest FM26 dataFitFavorite noteRemove
+
ComparePlayerKnowledgeRole & fitForm & marketShortlist noteRemove
{visible.map(({ player, note }) => ( -
- toggleCompare(player.id)} /> -
{player.name}{player.age ?? "Age unknown"} · {player.positions.join(" / ")} · {player.bestRole ?? "Role unknown"}
-
{player.form ?? "Form unknown"}{player.value ?? "Value unknown"} · {player.contractStatus ?? "Contract unknown"}
-
{player.roleFit == null ? "—" : `${player.roleFit}% role`}{player.tacticalFit == null ? "Tactical —" : `${player.tacticalFit}% tactical`}
- onUpdateNote(player.id, event.target.value)} /> - +
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)} /> +
))}
) : ( -

No favorited players

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

+

No shortlisted players

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

)} {compareIds.length === 2 ? (

Comparison

{compareIds.map((id) => { - const player = snapshot.players.find((item) => item.id === id); + const player = allFavorites.find((item) => item.id === id); return player ?
{player.name}{player.bestRole ?? "Role unknown"}{player.roleFit == null ? "Role fit unknown" : `${player.roleFit}% role fit`}{player.tacticalFit == null ? "Tactical fit unknown" : `${player.tacticalFit}% tactical fit`}
: null; })}
diff --git a/src/components/glass-scout-app.tsx b/src/components/glass-scout-app.tsx index 2fe5665..f639216 100644 --- a/src/components/glass-scout-app.tsx +++ b/src/components/glass-scout-app.tsx @@ -1,25 +1,27 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { AppSidebar, type Screen } from "@/components/app-sidebar"; 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"; import { SettingsScreen } from "@/components/settings-screen"; +import { ClubProfileScreen } from "@/components/club-profile-screen"; import { TooltipProvider } from "@/components/ui/tooltip"; import { - chooseAndImportFmfTactic, fm26LiveAdapter, - mergeTacticFile, + indexedPlayerProfile, + searchIndexedPlayers, + type IndexedPlayerSearchResult, type LiveConnectorStatus, type LiveFootballSnapshot, + type LivePlayer, } from "@/domain/adapters"; import { toggleFavorite, updateFavoriteNote, type FavoriteRecord } from "@/domain/live-data"; @@ -57,7 +59,8 @@ const initialStatus: LiveConnectorStatus = { savePointer: null, managedClubPointer: null, playerCollectionPointer: null, - liveMemoryTacticRead: "disabled", + liveMemoryTacticRead: "not_run", + tacticManagerPointer: null, failureStage: null, lastSuccessfulRead: null, windowsErrorCode: null, @@ -75,20 +78,47 @@ const initialSnapshot: LiveFootballSnapshot = { players: [], tactic: null, tacticSource: "none", - tacticFileStatus: "not_imported", - tacticFileName: null, - tacticFileWarnings: [], dataError: "Diagnostics have not run yet.", dataSource: "none", 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"); + const [screen, setScreenState] = useState("Dashboard"); + const [screenHistory, setScreenHistory] = useState(["Dashboard"]); + const [historyIndex, setHistoryIndex] = useState(0); const [search, setSearch] = useState(""); 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 []; const stored = window.localStorage.getItem("glassscout-favorites-v1"); @@ -101,12 +131,46 @@ export function GlassScoutApp() { } }); const [checking, setChecking] = useState(false); - const [importingTactic, setImportingTactic] = useState(false); useEffect(() => { window.localStorage.setItem("glassscout-favorites-v1", JSON.stringify(favorites)); }, [favorites]); + useEffect(() => { + const timer = window.setTimeout(() => { + if (!search.trim()) { + setGlobalIndexed([]); + return; + } + searchIndexedPlayers(search) + .then((items) => setGlobalIndexed(items.slice(0, 8))) + .catch(() => setGlobalIndexed([])); + }, search.trim() ? 160 : 0); + return () => window.clearTimeout(timer); + }, [search]); + + const navigate = useCallback((nextScreen: Screen) => { + if (nextScreen === screen) return; + const nextIndex = historyIndex + 1; + setScreenHistory((current) => [...current.slice(0, nextIndex), nextScreen]); + setHistoryIndex(nextIndex); + setScreenState(nextScreen); + }, [historyIndex, screen]); + + const goBack = useCallback(() => { + if (historyIndex <= 0) return; + const nextIndex = historyIndex - 1; + setHistoryIndex(nextIndex); + setScreenState(screenHistory[nextIndex]); + }, [historyIndex, screenHistory]); + + const goForward = useCallback(() => { + if (historyIndex >= screenHistory.length - 1) return; + const nextIndex = historyIndex + 1; + setHistoryIndex(nextIndex); + setScreenState(screenHistory[nextIndex]); + }, [historyIndex, screenHistory]); + const checkConnection = useCallback(async () => { setChecking(true); try { @@ -117,28 +181,60 @@ export function GlassScoutApp() { setChecking(false); } }, []); + const enterWorkspace = useCallback(() => { - setScreen("Dashboard"); + setScreenState("Dashboard"); + setScreenHistory(["Dashboard"]); + setHistoryIndex(0); setMode("fm26"); }, []); const togglePlayerFavorite = (playerId: string) => setFavorites((current) => toggleFavorite(current, playerId)); const updatePlayerNote = (playerId: string, note: string) => setFavorites((current) => updateFavoriteNote(current, playerId, note)); - const importTactic = useCallback(async () => { - setImportingTactic(true); + + const loadIndexedPlayerProfile = useCallback(async (playerId: string) => { try { - const result = await chooseAndImportFmfTactic(); - if (result) { - setSnapshot((current) => mergeTacticFile(current, result)); - } - } finally { - setImportingTactic(false); + 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 = (playerId: string) => { + + const openPlayer = useCallback((playerId: string) => { + setReturnScreen((current) => screen === "Player Profile" || screen === "Club Profile" ? current : screen); setSelectedPlayerId(playerId); - setScreen("Player Profile"); - }; + navigate("Player Profile"); + void loadIndexedPlayerProfile(playerId); + }, [loadIndexedPlayerProfile, navigate, screen]); + + const openClub = useCallback((clubId: string) => { + setReturnScreen((current) => screen === "Player Profile" || screen === "Club Profile" ? current : screen); + setSelectedClubId(clubId); + navigate("Club Profile"); + }, [navigate, screen]); + + const goBackOrReturn = useCallback(() => { + if (historyIndex > 0) { + goBack(); + return; + } + navigate(returnScreen); + }, [goBack, historyIndex, navigate, returnScreen]); + + const globalClubs = useMemo( + () => search.trim() + ? snapshot.clubs.filter((club) => club.name.toLowerCase().includes(search.trim().toLowerCase())).slice(0, 4) + : [], + [search, snapshot.clubs], + ); if (mode === null) { return ( @@ -149,28 +245,31 @@ export function GlassScoutApp() { } const content = - screen === "Dashboard" ? : - screen === "Squad Planner" ? : - screen === "Tactical Board" ? : - screen === "Reports" ? : - screen === "Recruitment" || screen === "Scout Room" || screen === "Players" ? : - screen === "Shortlist" ? : + screen === "Dashboard" ? : + screen === "Squad" ? : + screen === "Tactical Board" ? : + screen === "Scout Room" ? : + screen === "Shortlist" ? : screen === "Player Profile" ? ( player.id === selectedPlayerId) ?? null} snapshot={snapshot} favorite={selectedPlayerId ? favorites.some((record) => record.playerId === selectedPlayerId) : false} onToggleFavorite={() => selectedPlayerId && togglePlayerFavorite(selectedPlayerId)} - onBack={() => setScreen("Players")} + onBack={goBackOrReturn} + onOpenClub={openClub} /> - ) : - ; + ) : screen === "Club Profile" ? ( + + ) : ( + + ); return (
- -
+
+ 0} + canGoForward={historyIndex < screenHistory.length - 1} + onGoBack={goBack} + onGoForward={goForward} /> {search ? ( - - Searching the current dataset for “{search}” - + +
Search results
+ {globalClubs.map((club) => ( + + ))} + {globalIndexed.map((result) => ( + + ))} + {!globalClubs.length && !globalIndexed.length ?

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

: null}
) : null} +
+
{content}
diff --git a/src/components/my-team-screen.tsx b/src/components/my-team-screen.tsx index f7ad06a..a8153ac 100644 --- a/src/components/my-team-screen.tsx +++ b/src/components/my-team-screen.tsx @@ -5,78 +5,80 @@ 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 PlayerRow({ player, onOpenPlayer }: { player: LivePlayer; onOpenPlayer: (playerId: string) => void }) { +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 shown(value: string | number | null | undefined) { + return value == null || value === "" ? "Unknown" : value; +} + +function PlayerRow({ player, onOpenPlayer }: { player: LivePlayer; onOpenPlayer: (id: string) => void }) { + const roleHint = player.playableRoles?.slice(1, 3).map((role) => role.shortRole).join(" / "); 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 - +
onOpenPlayer(player.id)}> +
+ + {shown(player.nationality)} · {shown(player.age)} yrs +
+ {player.positions.join(" / ") || "Unknown"}{player.preferredFoot ? `${player.preferredFoot} foot` : "Foot unknown"} + {shown(player.bestRole)}{roleHint ? `Also ${roleHint}` : "Role evidence"} + 0–100 + {shown(player.averageRating?.toFixed(2))}Current form + {shown(player.contractStatus)}{shown(player.wage)} wage + {shown(player.value)}Valuation + {shown(player.condition)}Fitness / injury + {shown(player.squadImportance)}{shown(player.personality)} personality +
); } -export function MyTeamScreen({ - snapshot, - checking, - onRefresh, - onOpenPlayer, -}: { +export function MyTeamScreen({ snapshot, checking, onRefresh, onOpenPlayer }: { snapshot: LiveFootballSnapshot; checking: boolean; onRefresh: () => Promise; onOpenPlayer: (playerId: string) => void; }) { - const squad = useMemo( - () => snapshot.players.filter((player) => player.clubId === snapshot.managedClubId), - [snapshot.managedClubId, snapshot.players], - ); + const squad = useMemo(() => snapshot.players.filter((player) => player.clubId === snapshot.managedClubId), [snapshot.managedClubId, snapshot.players]); 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; - return ( -
-

{group}{players.length}

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

{group}{players.length}

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

Live 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.

-
-
); } diff --git a/src/components/player-face.tsx b/src/components/player-face.tsx new file mode 100644 index 0000000..7604ecd --- /dev/null +++ b/src/components/player-face.tsx @@ -0,0 +1,48 @@ +"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", highResolution = false }: { + playerId: string; + name: string; + size?: "sm" | "md" | "lg"; + highResolution?: boolean; +}) { + 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: useIcon }) + .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, useIcon]); + + return ( + + {source ? {`${name} : } + + ); +} diff --git a/src/components/player-profile-screen.tsx b/src/components/player-profile-screen.tsx index 53140bb..3878461 100644 --- a/src/components/player-profile-screen.tsx +++ b/src/components/player-profile-screen.tsx @@ -17,17 +17,35 @@ 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 { 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", "Consistency"]; -const physicalAttributes = ["Acceleration", "Pace", "Agility", "Balance", "Strength", "Stamina", "Injury Proneness"]; +const technicalAttributes = ["Crossing", "Dribbling", "Finishing", "First Touch", "Heading", "Long Shots", "Marking", "Passing", "Tackling", "Technique"]; +const mentalAttributes = ["Aggression", "Anticipation", "Bravery", "Composure", "Concentration", "Decisions", "Determination", "Flair", "Leadership", "Off the Ball", "Positioning", "Teamwork", "Vision", "Work Rate"]; +const physicalAttributes = ["Acceleration", "Agility", "Balance", "Jumping Reach", "Natural Fitness", "Pace", "Stamina", "Strength"]; +const setPieceAttributes = ["Corners", "Free Kick Taking", "Penalty Taking", "Long Throws"]; +const goalkeepingAttributes = ["Aerial Reach", "Command of Area", "Communication", "Eccentricity", "Handling", "Kicking", "One on Ones", "Punching", "Reflexes", "Rushing Out", "Throwing"]; function evidenceValue(player: LivePlayer, attribute: string) { const value = player.attributes?.[attribute]; 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 (
@@ -39,8 +57,55 @@ function AttributeGroup({ title, names, player }: { title: string; names: string ); } -function UnknownRing() { - return Unknown; +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.

+
+ ); +} + +function RoleFitCards({ player, limit = 6 }: { player: LivePlayer; limit?: number }) { + const roles = player.playableRoles?.slice(0, limit) ?? []; + if (!roles.length) { + return

No playable FM26 role score has been validated from the current mapped attributes.

; + } + return ( +
+ {roles.map((role) => ( +
+ {role.shortRole}{role.score} +
+ {role.role} + {role.positions.join(" / ")} · position {role.positionFit}% · attributes {role.attributeFit ?? "—"}% +
+
+ ))} +
+ ); } export function PlayerProfileScreen({ @@ -49,25 +114,28 @@ export function PlayerProfileScreen({ favorite, onToggleFavorite, onBack, + onOpenClub, }: { player: LivePlayer | null; snapshot: LiveFootballSnapshot; favorite: boolean; onToggleFavorite: () => void; onBack: () => void; + onOpenClub?: (clubId: string) => void; }) { if (!player) { 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 = 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,30 +143,31 @@ export function PlayerProfileScreen({
-

{player.name}

Visibility-safe live FM26 dossier

+ +

{player.name}

Live FM26 player dossier

- +
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"].map((value) => ( {value === "tactical" ? "Tactical fit" : value[0].toUpperCase() + value.slice(1)} @@ -111,23 +180,22 @@ 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. + GlassScout identifies {player.name} ({player.nationality ?? "nationality unknown"}, age {player.age ?? "unknown"}) at {clubName ?? "an unmapped club"}.

-

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 live formation-specific fit." : "Keep scouting while the live tactic layout is validated."}
-

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}%`}
@@ -140,19 +208,23 @@ export function PlayerProfileScreen({ Low fit
+

Attribute evidence

Visible values only
-
+
+ +
-
Last scouted
Unknown
+
Last scouted
{player.lastScoutedDate ?? "Unknown"}
Observations
Unknown
-
Report reliability
Unknown
+
Report reliability
{player.reportReliability ?? "Unknown"}
@@ -182,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"}
@@ -191,21 +263,61 @@ export function PlayerProfileScreen({

Decision history

-
No visibility-safe decisions recorded
+
No scouting decisions recorded
- {["tactical", "attributes", "performance", "career", "reports", "notes"].map((value) => ( - -
- -

{value === "tactical" ? "Tactical fit" : value[0].toUpperCase() + value.slice(1)} evidence is not available

-

GlassScout will populate this section only from FM26 data that is visible to the user’s club.

-
-
- ))} + +
+

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.`}

+
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."}

+
+ +
+
+ +
+

Attribute profile

{mappedAttributeCount} visible values
+
+ + + + + +
+
+
+ +
+

Performance and per 90

+
+ {Object.entries({ "Average rating": player.averageRating, Minutes: player.minutesPlayed, Goals: player.goals, Assists: player.assists, ...player.per90 }).map(([label, value]) => ( +
{metricLabel(label)}{metricValue(metricLabel(label), value)}
+ ))} +
+

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

+
+
+ +
+

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 ae8b43b..aa6ec56 100644 --- a/src/components/recruitment-screen.tsx +++ b/src/components/recruitment-screen.tsx @@ -1,21 +1,161 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { + ArrowDownUp, + BookmarkPlus, ChevronDown, + ChevronLeft, + ChevronRight, + Columns3, Database, - ExternalLink, + Download, + LayoutGrid, + List, + MoreHorizontal, Search, ShieldCheck, + SlidersHorizontal, + Sparkles, Star, + Target, + TrendingUp, + Users, } from "lucide-react"; -import type { LiveFootballSnapshot, LivePlayer } from "@/domain/adapters"; +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"; -const knownInterest = new Set([ +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; + joiningInterest: string; + availability: string; + hasMappedJoiningInterest: boolean; + 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; +}; + +type TacticNeed = { + id: string; + slotLabel: string; + roleLabel: string; + currentPlayer: string; + currentFit: number | null; + depth: number; + bestTarget: PlayerRow | null; + severity: "good" | "watch" | "need"; + note: string; +}; + +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", @@ -25,34 +165,619 @@ const knownInterest = new Set([ "Only if guaranteed playing time", ]); -function interestLabel(player: LivePlayer) { - return player.transferInterest ?? player.loanInterest ?? "Unknown"; +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, + 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, + notForSale: result.notForSale ?? 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 availabilityLabel(player: LivePlayer) { - if (player.transferAvailable === true) return "Transfer"; - if (player.loanAvailable === true) return "Loan"; - if (player.transferAvailable === false && player.loanAvailable === false) return "Not available"; +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) { + 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"; + } +} + +const availabilityOnlyLabels = new Set(["Transfer listed", "Loan listed", "Not for sale", "Open"]); + +function cleanMetadataLabel(value: string | null | undefined) { + return value?.trim() || null; +} + +function isAvailabilityOnlyLabel(value: string | null | undefined) { + const label = cleanMetadataLabel(value); + return label ? availabilityOnlyLabels.has(label) : false; +} + +function joiningInterestLabel(player: LivePlayer) { + const transfer = cleanMetadataLabel(player.transferInterest); + if (transfer && !isAvailabilityOnlyLabel(transfer)) return transfer; + const loan = cleanMetadataLabel(player.loanInterest); + if (loan && !isAvailabilityOnlyLabel(loan)) return loan; 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 availabilityLabel(player: LivePlayer) { + const transfer = cleanMetadataLabel(player.transferInterest); + if (transfer && isAvailabilityOnlyLabel(transfer)) return transfer; + const loan = cleanMetadataLabel(player.loanInterest); + if (loan && isAvailabilityOnlyLabel(loan)) return loan; + if (player.notForSale) return "Not for sale"; + if (player.transferAvailable) return "Transfer listed"; + if (player.loanAvailable) return "Loan listed"; + return "Standard"; +} + +function hasMappedJoiningInterest(player: LivePlayer) { + return joiningInterestLabel(player) !== "Unknown"; +} + +function interestScore(label: string) { + const normalized = label.toLowerCase(); + if (normalized.includes("very interested")) return 100; + if (normalized === "interested") return 84; + if (normalized.includes("slightly")) return 64; + if (normalized.includes("loan possible")) return 58; + if (normalized.includes("wages") || normalized.includes("playing time") || normalized.includes("promoted")) return 45; + if (normalized.includes("doubtful")) return 30; + if (normalized.includes("not interested")) return 5; + return 34; +} + +function availabilityScore(label: string) { + const normalized = label.toLowerCase(); + if (normalized.includes("not for sale")) return 16; + if (normalized.includes("transfer listed")) return 74; + if (normalized.includes("loan listed")) return 66; + return 48; +} + +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 playerMatchesSlot(player: LivePlayer, slotPosition: string | null | undefined) { + const slotTokens = new Set(positionTokens(slotPosition)); + if (!slotTokens.size) return false; + return player.positions.flatMap(positionTokens).some((token) => slotTokens.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 = joiningInterestLabel(player); + const availability = availabilityLabel(player); + const market = valueRealismScore(player, roleFit, performance); + const confidence = player.scoutConfidence ?? 0; + 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.16 + + interestScore(interest) * 0.08 + + availabilityScore(availability) * 0.04 + + clamp(confidence) * 0.06; + + if (tacticMatch === false) base -= 8; + if (!hasMappedJoiningInterest(player)) base -= 4; + if (availability.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}`, + interest === "Unknown" ? "joining interest unmapped" : `interest ${interest}`, + `availability ${availability}`, + `${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); + const joiningInterest = joiningInterestLabel(player); + return { + player, + clubName, + clubId, + knowledgeLabel: knowledgeLabel(player), + roleFitScore: roleFit, + tacticPositionMatch: tacticMatch, + recommendation: recommendationFor(player, roleFit, tacticMatch), + joiningInterest, + availability: availabilityLabel(player), + hasMappedJoiningInterest: joiningInterest !== "Unknown", + 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 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 SavedFilterButton({ value, active, label, detail, icon, onClick }: { + value: SavedFilter; + active: SavedFilter; + label: string; + detail: string; + icon: React.ReactNode; + onClick: (value: SavedFilter) => void; +}) { + 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); + }); } -export function RecruitmentScreen({ - snapshot, - favorites, - checking, - onRefresh, - onToggleFavorite, - onOpenPlayer, -}: { +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; +} + +function tacticNeedsFor(snapshot: LiveFootballSnapshot, rows: PlayerRow[]): TacticNeed[] { + const slots = snapshot.tactic?.slots ?? []; + if (!slots.length) return []; + + const managedClubId = snapshot.managedClubId; + const ownRows = rows.filter((row) => managedClubId && row.player.clubId === managedClubId); + const targetRows = rows + .filter((row) => !managedClubId || row.player.clubId !== managedClubId) + .filter((row) => (row.recommendation.score ?? 0) >= 40) + .toSorted((left, right) => (right.recommendation.score ?? -1) - (left.recommendation.score ?? -1)); + + return slots.slice(0, 11).map((slot, index) => { + const slotLabel = slot.position || `Slot ${index + 1}`; + const current = rows.find((row) => row.player.id === slot.playerId); + const currentFit = current?.roleFitScore ?? null; + const depth = ownRows.filter((row) => playerMatchesSlot(row.player, slotLabel) && (row.roleFitScore ?? 0) >= 50).length; + const bestTarget = targetRows.find((row) => playerMatchesSlot(row.player, slotLabel)) ?? null; + const severity: TacticNeed["severity"] = + currentFit == null || currentFit < 50 || depth < 2 + ? "need" + : currentFit < 64 || depth < 3 + ? "watch" + : "good"; + const note = + severity === "good" + ? "covered" + : bestTarget + ? `${bestTarget.recommendation.label} available in Scout Room` + : "no strong external candidate in current filters"; + + return { + id: `${slotLabel}-${slot.playerId ?? index}`, + slotLabel, + roleLabel: [slot.role, slot.duty].filter(Boolean).join(" · ") || "Role decoder pending", + currentPlayer: current?.player.name ?? "Not picked", + currentFit, + depth, + bestTarget, + severity, + note, + }; + }); +} + +export function ScoutRoomScreen({ snapshot, favorites, checking, onRefresh, onToggleFavorite, onOpenPlayer }: { snapshot: LiveFootballSnapshot; favorites: FavoriteRecord[]; checking: boolean; @@ -60,105 +785,412 @@ export function RecruitmentScreen({ onToggleFavorite: (playerId: string) => void; 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 [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(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.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 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 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 players = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - return visibleTargets - .filter((player) => { - const club = player.clubId ? clubById.get(player.clubId) : null; - const matchesQuery = !normalizedQuery || [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); - return matchesQuery - && matchesPosition - && (interest === "All interest" || playerInterest === interest) - && (realism === "All realism" || playerRealism === realism) - && (transferType === "All types" || playerType === transferType); - }) - .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]); + + 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)); + } + return [...merged.values()]; + }, [indexed, snapshot.players]); + + 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(); + 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 || (row.hasMappedJoiningInterest && interestedLabels.has(row.joiningInterest))) + && (position === "All" || player.positions.includes(position)) + && (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.joiningInterest === interestFilter) + && (row.roleFitScore == null || row.roleFitScore >= minRoleFit) + && (player.age == null || player.age <= maxAge) + && (row.valueAmount == null || row.valueAmount <= maxMarketValue) + && rowMatchesSavedFilter(row, savedFilter, favoriteIds); + }); + }, [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); + const mappedInterest = rows.filter((row) => row.hasMappedJoiningInterest).length; + return { indexedCount, fullyKnown, wonderkids, bargains, avgRecommendation, scoutedThisWeek, topTacticFit, mappedInterest }; + }, [rows, snapshot.status.databasePlayersIndexed, snapshot.status.fullyScoutedPlayers, snapshot.status.partialScoutReports]); + + const tacticNeeds = useMemo(() => tacticNeedsFor(snapshot, rows), [rows, snapshot]); + + 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" || value === "age" || value === "value" || value === "wage" ? "asc" : "desc"); + } + }; if (snapshot.status.state !== "connected") { - return
; + return
; } return ( -
-
+
+
-

Recruitment Hub

-

Only club-visible players can enter this decision workflow.

+

Scout Room

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

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

+
+
+ + +
-
{snapshot.status.databasePlayersIndexed} indexed · {snapshot.status.visiblePlayersLoaded} visible
-
- -
-
setQuery(event.target.value)} />
- - - -
-
+
+ } 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+"} /> +
+ +
- PlayerRole & tactical fitKnowledgeInterest & realismFinancialsAvailabilityProjection / riskNext action -
- {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.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`}
-
- - -
-
- ); - })} - {!players.length ? ( -
- -

No visibility-safe recruitment targets

+
+ Tactic-linked recruitment +

Squad needs from the live tactic

- GlassScout indexed {snapshot.status.backgroundPlayersIndexed} wider-save player records, but none can be shown until FM26 scout-knowledge visibility is mapped and validated. + Uses validated FM26 tactic slots, squad depth, role fit, performance evidence, value, wage and mapped joining-interest metadata.

- ) : null} +
+ {tacticNeeds.filter((need) => need.severity === "need").length} urgent slots + {formatCount(metrics.mappedInterest)} with mapped interest +
+ + {tacticNeeds.length ? ( +
+ {tacticNeeds.map((need) => ( +
+
+ {need.slotLabel} + {need.roleLabel} +
+
+
Current
{need.currentPlayer}
+
Fit
{need.currentFit == null ? "Unknown" : `${need.currentFit}/100`}
+
Depth
{need.depth} options
+
+ {need.bestTarget ? ( + + ) : ( +

{need.note}

+ )} +
+ ))} +
+ ) : ( +
+ + Live tactic slots are not validated yet. + Scout Room remains searchable, but tactic-linked squad-gap suggestions need a validated tactic read. +
+ )} +
+ +
+ {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.joiningInterest} + {row.availability} + + {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"} +
+
); diff --git a/src/components/role-dna-screen.tsx b/src/components/role-dna-screen.tsx index e0c17ca..5f47f9c 100644 --- a/src/components/role-dna-screen.tsx +++ b/src/components/role-dna-screen.tsx @@ -4,28 +4,63 @@ import { Dna, RefreshCw } from "lucide-react"; import type { LiveFootballSnapshot } from "@/domain/adapters"; import { LiveDataState } from "@/components/live-data-state"; -export function RoleDnaScreen({ snapshot, checking, onRefresh }: { snapshot: LiveFootballSnapshot; checking: boolean; onRefresh: () => Promise }) { +export function RoleDnaScreen({ + snapshot, + checking, + onRefresh, +}: { + snapshot: LiveFootballSnapshot; + checking: boolean; + onRefresh: () => Promise; +}) { if (snapshot.status.state !== "connected" || !snapshot.players.length) { - return
; + return ( +
+ +
+ ); } + const players = snapshot.players.toSorted((a, b) => (b.roleFit ?? -1) - (a.roleFit ?? -1)); + return (
-

Role DNA / Position Converter

Ability-based positions from visible technical, mental and physical attributes.

{players.length} players analysed
+
+
+

Role DNA / Position Converter

+

FM26 split-fit scoring: in-possession role, out-of-possession role, then combined tactical fit from visible attributes.

+
+
{players.length} players analysed
+
+
-
PlayerCurrent positionBest ability-based positionBest roleDNA scoreRetrainingReasoning
- {players.map((player) => ( -
- {player.name} - {player.positions.join(" / ") || "Unknown"} - {player.bestCalculatedPosition ?? "Unavailable"} - {player.bestRole ?? "Unavailable"} - {player.roleFit == null ? "—" : `${player.roleFit}%`} - {player.retrainingSuggestion ?? "No change suggested"} - {player.roleReasoning?.join(" · ") || "More visible attributes required"} -
- ))} +
+ Player + Position + Best mapped position + IP role + OOP role + Combined + Red flags + Reasoning +
+ {players.map((player) => { + const best = player.playableRoles?.[0]; + return ( +
+ {player.name} + {player.positions.join(" / ") || "Unknown"} + {player.bestCalculatedPosition ?? "Unavailable"} + {best?.inPossessionRole ?? "Unavailable"} + {best?.outOfPossessionRole ?? "Unavailable"} + {player.roleFit == null ? "—" : `${player.roleFit}%`} + {best?.redFlags?.slice(0, 2).join(" · ") || "No major flag"} + {player.roleReasoning?.join(" · ") || "More visible attributes required"} +
+ ); + })}
+
Scores update whenever GlassScout reads a newer live-game snapshot.
); diff --git a/src/components/settings-screen.tsx b/src/components/settings-screen.tsx index bf919f3..8b22ed4 100644 --- a/src/components/settings-screen.tsx +++ b/src/components/settings-screen.tsx @@ -1,8 +1,11 @@ "use client"; +import { useEffect, useState } from "react"; import { ChevronDown, Cpu, Database, LockKeyhole, RefreshCw } from "lucide-react"; import type { LiveFootballSnapshot } from "@/domain/adapters"; +import { captureMappingEvidence, compareMappingEvidence, getMappingLabStatus, type MappingLabCaptureResult, type MappingLabComparisonResult, type MappingLabStatus } from "@/domain/adapters"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; export function SettingsScreen({ snapshot, @@ -14,6 +17,31 @@ export function SettingsScreen({ onRefresh: () => Promise; }) { const status = snapshot.status; + const [mappingLab, setMappingLab] = useState(null); + const [mappingTarget, setMappingTarget] = useState(""); + const [mappingLabel, setMappingLabel] = useState(""); + const [capture, setCapture] = useState(null); + const [captureError, setCaptureError] = useState(null); + const [firstSnapshot, setFirstSnapshot] = useState(""); + const [secondSnapshot, setSecondSnapshot] = useState(""); + const [comparison, setComparison] = useState(null); + useEffect(() => { getMappingLabStatus().then(setMappingLab).catch(() => setMappingLab(null)); }, []); + const captureEvidence = async () => { + setCaptureError(null); + try { + setCapture(await captureMappingEvidence(mappingTarget, mappingLabel)); + } catch (error) { + setCaptureError(error instanceof Error ? error.message : String(error)); + } + }; + const compareEvidence = async () => { + setCaptureError(null); + try { + setComparison(await compareMappingEvidence(firstSnapshot, secondSnapshot)); + } catch (error) { + setCaptureError(error instanceof Error ? error.message : String(error)); + } + }; return (
@@ -36,6 +64,7 @@ export function SettingsScreen({
Module base
{status.moduleBase ?? "Unavailable"}
Memory probe
{status.executableHeaderValid ? `Passed · ${status.bytesRead} bytes` : "Not verified"}
Entity map
{status.entityMapStatus === "matched" ? status.entityMapProfileId : status.entityMapStatus ?? "Not checked"}
+
Mapping schema
v{status.mappingSchemaVersion ?? 2}
Pointer validation
{status.pointerValidation?.replaceAll("_", " ") ?? "Not run"}
Active save
{status.saveDetected === true ? "Detected" : "Not readable"}
Read-only access flags
{status.handleAccessFlags ?? "Unavailable"}
@@ -48,18 +77,39 @@ 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"}
+
Tactic manager
{status.tacticManagerPointer ?? "Unavailable"}
Tactic source
{snapshot.tacticSource.replaceAll("_", " ")}
-
FMF parser status
{snapshot.tacticFileStatus.replaceAll("_", " ")}
-
Imported FMF
{snapshot.tacticFileName ?? "None"}
Last successful read
{status.lastSuccessfulRead?.replaceAll("_", " ") ?? "None"}
Failure stage
{status.failureStage?.replaceAll("_", " ") ?? "None"}
Windows error
{status.windowsErrorCode ?? "None"}
Executable SHA-256
{status.executableSha256 ?? "Unavailable"}
+
+
Exact-build mapping coverageOnly validated fields enter the live product.
+ {(status.mappingCoverage ?? []).map((item) => ( +
+ {item.section} + {item.validated} validated + {item.candidate} candidates + {item.unmapped} unmapped +
+ ))} +
+ {mappingLab?.enabled ? ( +
+
Developer Mapping LabRead-only · bounded to {mappingLab.maximumWindowBytes} bytes per object
+

{mappingLab.message}

+
setMappingTarget(event.target.value)} /> setMappingLabel(event.target.value)} />
+ {capture ? Saved {capture.windowsCaptured} windows ({capture.bytesCaptured} bytes) to {capture.evidenceFile} : null} +
setFirstSnapshot(event.target.value)} /> setSecondSnapshot(event.target.value)} />
+ {comparison ? Diff: {comparison.changedBytes} changed, {comparison.unchangedBytes} unchanged bytes. Evidence: {comparison.evidenceFile} : null} + {captureError ? {captureError} : null} +
+ ) : null}
Connector result{status.message}
diff --git a/src/components/startup-screen.tsx b/src/components/startup-screen.tsx index 103f275..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", }; @@ -24,7 +24,7 @@ export function StartupScreen({ onConnect: () => Promise; onEnter: (status: LiveConnectorStatus) => void; }) { - const desktopRuntime = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + const [desktopRuntime] = useState(() => typeof window !== "undefined" && "__TAURI_INTERNALS__" in window); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(null); const [loadStage, setLoadStage] = useState("detecting_fm26"); @@ -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 64f277f..a74aaa7 100644 --- a/src/components/tactical-board.tsx +++ b/src/components/tactical-board.tsx @@ -1,111 +1,200 @@ "use client"; -import { useState, type CSSProperties } from "react"; -import { FileUp, 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"; -import { Button } from "@/components/ui/button"; type TacticalPhase = "In Possession" | "Out of Possession" | "Combined"; -const fallbackCoordinates = [ - [50, 86], - [13, 69], - [37, 70], - [63, 70], - [87, 69], - [50, 53], - [28, 45], - [72, 45], - [18, 22], - [50, 16], - [82, 22], -]; - -function coordinatesForSlot(slot: LiveTacticSlot, index: number) { - const value = slot.position.toUpperCase(); - if (value.includes("GK")) return [50, 86]; - if (value.includes("DL") || value.includes("LB")) return [13, 69]; - if (value.includes("DR") || value.includes("RB")) return [87, 69]; - if (value.includes("DC") || value.includes("CB")) return index % 2 ? [38, 70] : [62, 70]; - if (value.includes("DM")) return [50, 55]; - if (value.includes("AML") || value.includes("LW")) return [18, 24]; - if (value.includes("AMR") || value.includes("RW")) return [82, 24]; - if (value.includes("AMC")) return [50, 32]; - 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 compact = value.toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (positionCoordinates[compact]) return compact; + if (compact === "CB") return "DC"; + if (compact === "CM") return "MC"; + if (compact === "LB") return "DL"; + if (compact === "RB") return "DR"; + if (compact === "LW") return "AML"; + if (compact === "RW") return "AMR"; + if (compact === "CF") return "ST"; + + const tokens = value + .toUpperCase() + .replace(/[(),]/g, " ") + .split(/[\s/|-]+/) + .filter(Boolean); + const side = tokens.find((token) => token === "R" || token === "L" || token === "C") ?? ""; + const has = (token: string) => tokens.includes(token); + + if (has("GK")) return "GK"; + if (has("SW")) return "SW"; + if (has("ST")) return side === "R" ? "STR" : side === "L" ? "STL" : "STC"; + if (has("AM")) return side === "R" ? "AMR" : side === "L" ? "AML" : "AMC"; + if (has("M")) return side === "R" ? "MR" : side === "L" ? "ML" : "MC"; + if (has("DM")) return side === "R" ? "DMR" : side === "L" ? "DML" : "DM"; + if (has("WB")) return side === "R" ? "WBR" : side === "L" ? "WBL" : "WB"; + if (has("D")) return side === "R" ? "DR" : side === "L" ? "DL" : "DC"; + + return compact; +} + +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) { - if (!playerId) return null; - return players.find((player) => player.id === playerId) ?? 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, - onImport, - importing = false, onOpenPlayer, }: { snapshot: LiveFootballSnapshot; compact?: boolean; - onImport?: () => void; - importing?: boolean; 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 ?

Roles and fit are shown only when the imported FMF has been decoded.

: 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) => ( - + ))}
- - - - - - + + +
{snapshot.tactic?.formation ?? "—"} - {snapshot.tactic ? phase : "No decoded tactic"} + {snapshot.tactic ? phase : "Live tactic pending"}
{slots.map((slot, index) => { const player = playerById(snapshot.players, slot.playerId); - const [left, top] = coordinatesForSlot(slot, index); - const fit = player?.roleFit; + 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 ( - ); })} @@ -113,21 +202,30 @@ export function TacticalBoard({ {!slots.length ? (
- {snapshot.tacticFileName ? "FMF imported, formation not decoded" : "No tactic loaded"} - - {snapshot.tacticFileName - ? "GlassScout will not invent a formation, role, duty or first XI." - : "Import the tactic selected in FM26 to activate this board."} - - {onImport ? ( - - ) : null} + {snapshot.status.liveMemoryTacticRead === "object_detected_unmapped" ? "Live tactic detected — slot decoder pending" : "Waiting for active FM26 tactic"} + GlassScout will not invent a formation, role, duty, instruction or first XI.
) : 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 4f41102..d3a19e4 100644 --- a/src/components/tactics-screen.tsx +++ b/src/components/tactics-screen.tsx @@ -1,101 +1,66 @@ "use client"; -import { AlertTriangle, CheckCircle2, FileArchive, Upload } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Cpu, ShieldCheck } from "lucide-react"; import type { LiveFootballSnapshot } from "@/domain/adapters"; import { TacticalBoard } from "@/components/tactical-board"; -import { Button } from "@/components/ui/button"; -function formatFileSize(value: number | null | undefined) { - if (value == null) return "Unknown size"; - if (value < 1024) return `${value} bytes`; - return `${(value / 1024).toFixed(1)} KB`; -} - -function formatImportDate(value: string | null | undefined) { - if (!value) return "Unknown date"; - const date = new Date(Number(value)); - return Number.isNaN(date.valueOf()) ? "Unknown date" : date.toLocaleString(); -} - -export function TacticsScreen({ - snapshot, - importing, - onImportTactic, -}: { - snapshot: LiveFootballSnapshot; - importing: boolean; - onImportTactic: () => Promise; -}) { - const parsed = snapshot.tactic != null && ( - snapshot.tacticFileStatus === "parsed" || snapshot.tacticFileStatus === "partially_parsed" - ); +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; + const rolesResolved = snapshot.tactic?.rolesResolved ?? 0; + const dutiesResolved = snapshot.tactic?.dutiesResolved ?? 0; + const roleDutyStatus = snapshot.tactic?.roleDutyDecoderStatus ?? "packet-not-validated"; return (

Tactical Board

-

Live squad context with a user-selected FMF tactic. No role or duty is inferred from memory.

+

The board follows the active tactic in the connected FM26 save. There is no file import or fallback tactic.

- +
Live FM26
- +
- - {snapshot.tacticFileWarnings.length ? ( -
- {snapshot.tacticFileWarnings.map((warning) =>

{warning}

)} -
- ) : null} - {snapshot.tacticFileErrors?.length ? ( -
- {snapshot.tacticFileErrors.map((error) => {error})} -
- ) : null}
); } diff --git a/src/components/topbar.tsx b/src/components/topbar.tsx index af21152..27fb4db 100644 --- a/src/components/topbar.tsx +++ b/src/components/topbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bell, Command, HelpCircle, RefreshCw, Search } from "lucide-react"; +import { Bell, ChevronLeft, ChevronRight, Command, HelpCircle, RefreshCw, Search } from "lucide-react"; import type { Screen } from "@/components/app-sidebar"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,10 @@ export function Topbar({ screen, checking, onRefresh, + canGoBack, + canGoForward, + onGoBack, + onGoForward, }: { search: string; onSearch: (value: string) => void; @@ -27,6 +31,10 @@ export function Topbar({ screen: Screen; checking: boolean; onRefresh: () => Promise; + canGoBack: boolean; + canGoForward: boolean; + onGoBack: () => void; + onGoForward: () => void; }) { const connected = snapshot.status.state === "connected"; const club = snapshot.clubs.find((item) => item.id === snapshot.managedClubId); @@ -34,23 +42,41 @@ export function Topbar({ return (
- {dashboard ? ( -
- Morning, {snapshot.managerName?.split(" ")[0] ?? "Manager"} - {club?.name ?? "Active club"} {snapshot.season ?? "Active save"} +
+
+ +
- ) : null} + + {dashboard ? ( +
+ Morning, {snapshot.managerName?.split(" ")[0] ?? "Manager"} + {club?.name ?? "Active club"} {snapshot.season ?? "Active save"} +
+ ) : ( +
+ {screen} + {club?.name ?? "Active club"} {snapshot.season ?? "Active save"} +
+ )} +
+
+
@@ -59,13 +85,12 @@ export function Topbar({ {syncLabel(snapshot.status.lastSync)}
{!dashboard ? ( <> - - -
TB
+ + ) : null}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b033601..5d0bc97 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { diff --git a/src/domain/adapters.ts b/src/domain/adapters.ts index 3b590b9..b7eb551 100644 --- a/src/domain/adapters.ts +++ b/src/domain/adapters.ts @@ -34,13 +34,16 @@ export type LiveConnectorStatus = { moduleBase?: string | null; entityMapStatus?: "missing" | "matched" | "invalid" | "not_checked"; entityMapProfileId?: string | null; + mappingSchemaVersion?: number; + mappingCoverage?: Array<{ section: string; validated: number; candidate: number; unmapped: number }>; pointerValidation?: "not_run" | "passed" | "failed"; handleAccessFlags?: string; entityRoot?: string | null; savePointer?: string | null; managedClubPointer?: string | null; playerCollectionPointer?: string | null; - liveMemoryTacticRead?: "disabled"; + liveMemoryTacticRead?: "not_run" | "object_not_found" | "object_detected_unmapped" | "ready"; + tacticManagerPointer?: string | null; failureStage?: string | null; lastSuccessfulRead?: string | null; windowsErrorCode?: number | null; @@ -53,11 +56,16 @@ export type LivePlayer = { id: string; name: string; age: number | null; + dateOfBirth?: string | null; nationality: string | null; + secondNationality?: string | null; positions: string[]; bestRole: string | null; + playableRoles?: PlayerRoleFit[]; + otherRoles?: PlayerRoleFit[]; currentAbility: number | null; potentialAbility: number | null; + abilityScore?: number | null; form: string | null; averageRating: number | null; minutesPlayed: number | null; @@ -70,16 +78,30 @@ export type LivePlayer = { developmentTrend: "improving" | "stable" | "declining" | null; tacticalFit: number | null; roleFit: number | null; + preferredFoot?: string | null; strengths: string[]; weaknesses: string[]; clubId: string | null; + clubName?: string | null; transferInterest: string | null; loanInterest: string | null; transferAvailable: boolean | null; loanAvailable: boolean | null; + notForSale?: 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; + reportReliability?: string | null; bestCalculatedPosition?: string | null; truePrice?: number | null; fairPriceRange?: [number, number] | null; @@ -89,6 +111,53 @@ export type LivePlayer = { roleReasoning?: string[]; riskLevel?: "low" | "medium" | "high" | "unknown"; 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>; +}; + +export type PlayerRoleFit = { + roleKey: string; + role: string; + shortRole: string; + roleIdMask: string; + positions: string[]; + score: number; + positionFit: number; + attributeFit: number | null; + evidence: string[]; + phase?: "in-possession" | "out-of-possession" | "combined"; + inPossessionRole?: string | null; + outOfPossessionRole?: string | null; + inPossessionFit?: number | null; + outOfPossessionFit?: number | null; + redFlags?: string[]; +}; + +export type FieldVisibility = "known" | "estimated" | "range" | "unknown"; + +export type KnowledgeField = { + value: T | null; + visibility: FieldVisibility; + source: "own-squad" | "scout-report" | "player-profile" | "data-hub" | "memory-raw"; + confidence: number; + lastValidated: string | null; +}; + +export type RecommendationEvidence = { + minimum: number | null; + maximum: number | null; + completeness: number; + label: string; }; export type LiveClub = { @@ -102,42 +171,34 @@ 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; }; export type LiveTactic = { name: string | null; formation: string; + formationEnum?: string; slots: LiveTacticSlot[]; teamInstructions: string[]; playerInstructionsReadable: boolean; + 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[]; }; -export type TacticSource = "none" | "fmf-file" | "live-memory"; - -export type TacticParserStatus = - | "not_imported" - | "imported_unparsed" - | "partially_parsed" - | "parsed" - | "unsupported_format" - | "invalid_file"; - -export type TacticFileResult = { - success: boolean; - fileName: string | null; - fileSize: number | null; - importedAt: string | null; - parserStatus: TacticParserStatus; - parsedFormation: string | null; - parsedRoles: string[]; - parsedDuties: string[]; - detectedFormat: string | null; - compressed: boolean | null; - encoded: boolean | null; - warnings: string[]; - errors: string[]; -}; +export type TacticSource = "none" | "live-memory"; export type LiveFootballSnapshot = { status: LiveConnectorStatus; @@ -148,13 +209,6 @@ export type LiveFootballSnapshot = { players: LivePlayer[]; tactic: LiveTactic | null; tacticSource: TacticSource; - tacticFileStatus: TacticParserStatus; - tacticFileName: string | null; - tacticFileWarnings: string[]; - tacticFileSize?: number | null; - tacticImportedAt?: string | null; - tacticFileErrors?: string[]; - tacticDetectedFormat?: string | null; dataError: string | null; dataSource?: "none" | "live-memory"; dataWarnings?: string[]; @@ -200,13 +254,16 @@ const desktopRequiredStatus: LiveConnectorStatus = { moduleBase: null, entityMapStatus: "not_checked", entityMapProfileId: null, + mappingSchemaVersion: 2, + mappingCoverage: [], pointerValidation: "not_run", handleAccessFlags: "Not available in browser", entityRoot: null, savePointer: null, managedClubPointer: null, playerCollectionPointer: null, - liveMemoryTacticRead: "disabled", + liveMemoryTacticRead: "not_run", + tacticManagerPointer: null, failureStage: null, lastSuccessfulRead: null, windowsErrorCode: null, @@ -215,49 +272,6 @@ const desktopRequiredStatus: LiveConnectorStatus = { warnings: ["No live data is being simulated."], }; -const notImportedTactic: TacticFileResult = { - success: false, - fileName: null, - fileSize: null, - importedAt: null, - parserStatus: "not_imported", - parsedFormation: null, - parsedRoles: [], - parsedDuties: [], - detectedFormat: null, - compressed: null, - encoded: null, - warnings: [], - errors: [], -}; - -export function mergeTacticFile( - snapshot: LiveFootballSnapshot, - file: TacticFileResult, -): LiveFootballSnapshot { - const hasParsedTactic = ( - file.parserStatus === "parsed" || file.parserStatus === "partially_parsed" - ) && file.parsedFormation != null; - return { - ...snapshot, - tactic: hasParsedTactic ? { - name: file.fileName, - formation: file.parsedFormation ?? "Unavailable", - slots: [], - teamInstructions: [], - playerInstructionsReadable: false, - } : null, - tacticSource: file.success ? "fmf-file" : "none", - tacticFileStatus: file.parserStatus, - tacticFileName: file.fileName, - tacticFileWarnings: file.warnings, - tacticFileSize: file.fileSize, - tacticImportedAt: file.importedAt, - tacticFileErrors: file.errors, - tacticDetectedFormat: file.detectedFormat, - }; -} - export const fm26LiveAdapter: FootballDataAdapter = { kind: "fm26-live", async getStatus() { @@ -288,9 +302,6 @@ export const fm26LiveAdapter: FootballDataAdapter = { players: [], tactic: null, tacticSource: "none", - tacticFileStatus: "not_imported", - tacticFileName: null, - tacticFileWarnings: [], dataError: desktopRequiredStatus.message, dataSource: "none", dataWarnings: desktopRequiredStatus.warnings, @@ -299,11 +310,7 @@ export const fm26LiveAdapter: FootballDataAdapter = { try { const { invoke } = await import("@tauri-apps/api/core"); - const [snapshot, tacticFile] = await Promise.all([ - invoke("load_active_save"), - invoke("tactic_file_status").catch(() => notImportedTactic), - ]); - return mergeTacticFile(snapshot, tacticFile); + return await invoke("load_active_save"); } catch (error) { const message = error instanceof Error ? error.message : "The desktop connector could not be reached."; return { @@ -315,9 +322,6 @@ export const fm26LiveAdapter: FootballDataAdapter = { players: [], tactic: null, tacticSource: "none", - tacticFileStatus: "not_imported", - tacticFileName: null, - tacticFileWarnings: [], dataError: message, dataSource: "none", dataWarnings: [message], @@ -326,28 +330,102 @@ export const fm26LiveAdapter: FootballDataAdapter = { }, }; -export async function chooseAndImportFmfTactic(): Promise { - if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) { - return { - ...notImportedTactic, - parserStatus: "invalid_file", - errors: ["FMF tactic import is available only in the installed Windows app."], - }; - } - const [{ open }, { invoke }] = await Promise.all([ - import("@tauri-apps/plugin-dialog"), - import("@tauri-apps/api/core"), - ]); - const selected = await open({ - title: "Choose an FM26 tactic", - multiple: false, - directory: false, - filters: [{ name: "Football Manager tactic", extensions: ["fmf"] }], - }); - if (selected == null || Array.isArray(selected)) { - return null; - } - return await invoke("import_tactic_file", { path: selected }); +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" | "partly_known" | "unknown"; + scoutConfidence: number; + bestRole?: string | null; + roleFit?: number | null; + value?: string | null; + wage?: string | null; + contractStatus?: string | null; + contractRemaining?: string | null; + averageRating?: number | null; + minutesPlayed?: number | null; + goals?: number | null; + assists?: number | null; + transferInterest?: string | null; + loanInterest?: string | null; + transferAvailable?: boolean | null; + loanAvailable?: boolean | null; + notForSale?: boolean | null; + per90?: Record; + rawStats?: Record; + inPossessionFit?: number | null; + outOfPossessionFit?: number | null; + projectedInPossessionFit?: number | null; + projectedOutOfPossessionFit?: number | null; + efficiencyScore?: number | null; + marketValueAmount?: number | null; +}; + +export async function searchIndexedPlayers(query: string): Promise { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return []; + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("search_indexed_players", { query }); +} + +export async function indexedPlayersByIds(playerIds: string[]): Promise { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window) || playerIds.length === 0) return []; + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("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; + maximumWindowBytes: number; + evidenceDirectory: string | null; + message: string; +}; + +export type MappingLabCaptureResult = { + success: boolean; + snapshotId: string; + evidenceFile: string; + playerId: string; + playerName: string; + windowsCaptured: number; + bytesCaptured: number; +}; + +export async function getMappingLabStatus(): Promise { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("mapping_lab_status"); +} + +export async function captureMappingEvidence(playerId: string, label: string): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("mapping_lab_capture", { playerId, label, windowSize: 1024 }); +} + +export type MappingLabComparisonResult = { + success: boolean; + firstSnapshotId: string; + secondSnapshotId: string; + changedBytes: number; + unchangedBytes: number; + evidenceFile: string; +}; + +export async function compareMappingEvidence(firstSnapshotId: string, secondSnapshotId: string): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("mapping_lab_compare", { firstSnapshotId, secondSnapshotId }); } export const realLifeAdapter: FutureRealLifeAdapter = { diff --git a/src/domain/distribution.ts b/src/domain/distribution.ts index 5a58f70..1f5f369 100644 --- a/src/domain/distribution.ts +++ b/src/domain/distribution.ts @@ -1 +1 @@ -export const windowsInstallerUrl = "https://github.com/TobiasTest22/GlassScout/releases/download/app-v0.1.4/GlassScout.FM26_0.1.4_x64-setup.exe"; +export const windowsInstallerUrl = "https://github.com/TobiasTest22/GlassScout/releases/download/app-v0.1.17/GlassScout.FM26_0.1.17_x64-setup.exe";