From 89fa69e3c8f2ceba8e8dc03cfae66ba0be43a2ed Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Wed, 6 May 2026 20:20:15 +0200 Subject: [PATCH] refactor: extract lib.rs, remove club module, add bounded concurrency - Extract shared types (Car, BestCarRecord, AnalysisContext) into src/lib.rs - Replace global static BEST_CARS with injected AnalysisContext for testability - Add semaphore-bounded concurrency (limit 20) in parse_all_times() - Replace hardcoded track name match with track_names.json loaded via include_str! - Replace magic number 115 with const MAX_RQ: u8 = 113 - Improve floating-point tie comparison with TIME_TOLERANCE (5ms) constant - Remove src/club.rs binary (topdrives.club no longer maintained) - Remove scraper dependency from Cargo.toml - Remove tracks.txt (replaced by track_names.json) - Remove empty/placeholder test bodies - Improve error handling in get_file() for response body read - Add load_my_cars_data() combining owned + wishlist extraction in one I/O - Update CHANGELOG, README, and all docs to reflect changes --- CHANGELOG.md | 60 ++-- Cargo.lock | 309 ----------------- Cargo.toml | 7 +- README.md | 59 +++- docs/API.md | 166 ++-------- docs/ARCHITECTURE.md | 81 ++--- docs/DATA_STRUCTURES.md | 59 +++- docs/DEVELOPMENT_SETUP.md | 32 +- docs/USER_GUIDE.md | 69 +++- src/club.rs | 661 ------------------------------------- src/lib.rs | 317 ++++++++++++++++++ src/records.rs | 675 +++++++++----------------------------- track_names.json | 141 ++++++++ tracks.txt | 275 ---------------- 14 files changed, 874 insertions(+), 2037 deletions(-) delete mode 100644 src/club.rs create mode 100644 src/lib.rs create mode 100644 track_names.json delete mode 100644 tracks.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b52557..af872c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,17 +14,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - User guide with command examples and troubleshooting - Data structures documentation for JSON formats - Development setup guide with dependency explanations +- `track_names.json` — external config file mapping track codes to human-readable names (loaded at compile time via `include_str!`) +- `full_script` command for running fetch + all analysis commands in sequence +- Daily workflow documentation with output file descriptions (`best.csv`, `details.csv`, `add.csv`, `remove.csv`, `trash.csv`, `sorted.csv`, `very_best.csv`, `wishlist.csv`) -### Improved +### Changed +- `translate_track_name()` in `lib.rs` now loads track name mappings from `track_names.json` instead of a hardcoded match statement +- Magic number 115 replaced with `const MAX_RQ: u8 = 113` in `records.rs` for clarity and maintainability +- `parse_all_times()` now uses a semaphore (limit 20) for bounded concurrency instead of unbounded task spawning +- Empty test bodies removed from `records.rs` - Enhanced error handling throughout the application - Better progress reporting during data fetching - More descriptive command-line help text - Improved CSV output formatting +### Removed +- **Club Module**: Removed `src/club.rs` binary (topdrives.club is no longer maintained) +- **scraper dependency**: Removed `scraper` crate from `Cargo.toml` (was only used by club module) +- **tracks.txt**: Removed track definitions file (only used by club binary) +- Club module `Car` struct (separate struct with `class`, `upgrade` fields) + ## [0.2.0] - 2024-12-XX ### Added -- **Club Module**: Alternative data source using web scraping from topdrives.club - **Track-Specific Analysis**: New `track` command for targeted recommendations - **Enhanced Output**: CSV file generation for analysis results - **Improved Concurrency**: Better handling of multiple simultaneous downloads @@ -64,33 +76,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Data Processing - **[0.1.0]** Basic JSON parsing and analysis - **[0.2.0]** Enhanced error handling and data validation -- **[Unreleased]** Improved caching and incremental updates +- **[Unreleased]** Track name mappings externalized to `track_names.json` ### Performance - **[0.1.0]** Single-threaded processing - **[0.2.0]** Concurrent downloads with rate limiting -- **[Unreleased]** Memory optimization and streaming processing +- **[Unreleased]** Bounded concurrency via semaphore (limit 20); `MAX_RQ` constant replaces magic number ### User Interface - **[0.1.0]** Basic command-line interface - **[0.2.0]** Enhanced output formatting and CSV export -- **[Unreleased]** Comprehensive documentation and help system +- **[Unreleased]** `full_script` command for complete daily workflow ### Data Sources - **[0.1.0]** JSON-based records from community repository - **[0.2.0]** Added HTML scraping from topdrives.club -- **[Unreleased]** Multiple data source integration +- **[Unreleased]** Removed club scraping (topdrives.club no longer maintained) ## Migration Guide +### Upgrading to Unreleased from 0.2.0 + +**Breaking Changes:** +1. **Club module removed**: `cargo run --bin club` is no longer available +2. **`scraper` dependency removed**: No longer needed without club module +3. **`tracks.txt` removed**: Track names now in `track_names.json` +4. **`translate_track_name()` changed**: Now loads from `track_names.json` instead of hardcoded match +5. **Magic number 115 → `MAX_RQ` (113)**: RQ range analysis now uses `MAX_RQ = 113` constant + +**New Features Available:** +1. **`full_script` command**: Run fetch + all analysis in one command +2. **Additional output files**: `add.csv`, `remove.csv`, `sorted.csv`, `very_best.csv`, `wishlist.csv` +3. **External track name config**: `track_names.json` can be updated without code changes + ### Upgrading from 0.1.0 to 0.2.0 **No breaking changes** - the upgrade is fully backwards compatible. **New Features Available:** -1. **Club Module**: Alternative data source with `cargo run --bin club` -2. **Track Command**: Specific track analysis with `cargo run --bin records -- track` -3. **CSV Output**: Automatic generation of analysis files +1. **Track Command**: Specific track analysis with `cargo run --bin records -- track` +2. **CSV Output**: Automatic generation of analysis files **Performance Improvements:** - Faster data fetching with concurrent downloads @@ -99,8 +124,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Deprecation Notices -### Future Removals -- **None currently planned** +### Removed in Unreleased +- **Club Module**: `src/club.rs` and `cargo run --bin club` — topdrives.club is no longer maintained +- **`scraper` dependency**: Removed from `Cargo.toml` +- **`tracks.txt`**: Replaced by `track_names.json` ### Deprecated Features - **None currently deprecated** @@ -122,7 +149,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Data Processing Speed - **Initial Load**: ~2-3 minutes (downloads 1000+ files) - **Subsequent Runs**: <30 seconds (uses cached data) -- **Concurrent Operations**: 20 simultaneous downloads +- **Concurrent Operations**: 20 simultaneous downloads (semaphore bounded) ### Memory Usage - **Base Application**: ~50MB @@ -132,7 +159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Network Efficiency - **Compression**: Efficient HTTP compression - **Connection Reuse**: Persistent HTTP connections -- **Rate Limiting**: Respects server constraints +- **Rate Limiting**: Semaphore-bounded concurrency (limit 20) ## Known Issues and Limitations @@ -150,15 +177,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Future Roadmap ### Planned Features (Unreleased) -- **Web Interface**: Browser-based GUI for non-technical users - **Historical Analysis**: Track performance changes over time -- **Mobile App**: iOS/Android companion application -- **Cloud Sync**: Synchronize collection across devices - **Advanced Analytics**: Statistical analysis and trend detection ### Potential Enhancements - **Real-time Updates**: Live data synchronization -- **Social Features**: Share collections and recommendations - **Customization**: User-defined analysis parameters - **Export Options**: Multiple file formats (PDF, Excel) @@ -184,11 +207,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Data Sources - **Top Drives Community**: Primary timing data source -- **topdrives.club**: Alternative data source - **GitHub Contributors**: Track and car database maintenance --- **Changelog Format**: Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format **Versioning**: Follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) principles -**Last Updated**: 2024-12-XX \ No newline at end of file +**Last Updated**: 2026-05-06 diff --git a/Cargo.lock b/Cargo.lock index 1d8bb83..6cc3c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,12 +32,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -86,40 +80,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cssparser" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -131,27 +91,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "ego-tree" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -225,16 +164,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -323,24 +252,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -405,18 +316,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - [[package]] name = "http" version = "1.4.0" @@ -717,37 +616,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "memchr" version = "2.8.0" @@ -788,12 +656,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "once_cell" version = "1.21.4" @@ -872,58 +734,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -945,12 +755,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "prettyplease" version = "0.2.37" @@ -985,21 +789,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1136,21 +925,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scraper" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2" -dependencies = [ - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "precomputed-hash", - "selectors", - "tendril", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -1174,25 +948,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" -dependencies = [ - "bitflags", - "cssparser", - "derive_more", - "fxhash", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "servo_arc", - "smallvec", -] - [[package]] name = "semver" version = "1.0.28" @@ -1253,15 +1008,6 @@ dependencies = [ "serde", ] -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1278,12 +1024,6 @@ dependencies = [ "libc", ] -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.12" @@ -1312,31 +1052,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - [[package]] name = "subtle" version = "2.6.1" @@ -1408,17 +1123,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -1496,7 +1200,6 @@ version = "0.2.0" dependencies = [ "futures", "reqwest", - "scraper", "serde_json", "tempfile", "tokio", @@ -1578,12 +1281,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -1608,12 +1305,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 0f034ae..67a8589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,16 +3,15 @@ name = "top_drives" version = "0.2.0" edition = "2021" -[[bin]] -name = "club" -path = "src/club.rs" +[lib] +name = "top_drives" +path = "src/lib.rs" [[bin]] name = "records" path = "src/records.rs" [dependencies] -scraper = "0.23.1" reqwest = "0.12.15" tokio = { version = "1.44.1", features = ["full"] } serde_json = "1.0.140" diff --git a/README.md b/README.md index 5371a66..6a5b77c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A comprehensive Rust-based tool for analyzing car performance data in the Top Dr - [Quick Start](#quick-start) - [Usage Guide](#usage-guide) - [Commands Reference](#commands-reference) +- [Daily Workflow](#daily-workflow) - [Data Sources](#data-sources) - [Configuration](#configuration) - [Development](#development) @@ -148,6 +149,36 @@ The analyzer supports multiple track conditions: | `details` | Show detailed timing data for all tracks | None | | `track` | Show best car for specific track | ` ` | +## 📅 Daily Workflow + +The typical daily workflow uses `full_script` which runs `fetch` followed by all analysis commands in sequence: + +```bash +cargo run --bin records -- full_script +``` + +This generates the following output files: + +| File | Description | +|------|-------------| +| `best.csv` | Ranked list of best cars — used to evaluate if a car is worth keeping | +| `details.csv` | Detailed timing data for all tracks/conditions — used to find the best config for a car | +| `add.csv` | Cars to add to wishlist — missing cars not yet wishlisted, prefixed with `+` | +| `remove.csv` | Cars to remove from wishlist — stale wishlist entries, prefixed with `-` | +| `trash.csv` | Owned cars that can be safely removed | +| `sorted.csv` | Best cars sorted by count (descending) | +| `very_best.csv` | Best cars with count ≥ 10 | +| `wishlist.csv` | Missing cars already on wishlist | + +### Workflow Steps + +1. **Fetch** fresh data from the community repository +2. **Analyze** your collection against the latest timing data +3. **Review** `best.csv` to see how your cars rank +4. **Check** `add.csv` for cars worth adding to your wishlist +5. **Clean up** `remove.csv` entries from your wishlist +6. **Sell** cars listed in `trash.csv` that are no longer useful + ## 📊 Data Sources ### Car Data @@ -160,6 +191,11 @@ The analyzer supports multiple track conditions: - **Coverage**: All tracks and conditions in Top Drives - **Format**: Structured track definitions with conditions +### Track Names +- **Source**: `track_names.json` (included in repository) +- **Format**: JSON mapping track codes to human-readable names +- **Loaded**: At compile time via `include_str!` + ### User Collection - **File**: `my_cars.json` - **Format**: Top Drives game export format @@ -172,10 +208,11 @@ The analyzer supports multiple track conditions: top_drives/ ├── src/ │ ├── records.rs # Main analyzer binary -│ └── club.rs # Alternative data source +│ └── lib.rs # Shared library (track name translations) ├── times/ # Car performance data (1000+ files) ├── cars_repo.json # Car database ├── tracks_repo.json # Track definitions +├── track_names.json # Track code → name mappings ├── my_cars.json # Your car collection └── Cargo.toml # Rust project configuration ``` @@ -184,15 +221,20 @@ top_drives/ - **reqwest**: HTTP client for data fetching - **serde_json**: JSON parsing and serialization - **tokio**: Asynchronous runtime -- **scraper**: HTML parsing for alternative data source +- **futures**: Async utility combinators +- **once_cell**: Lazy static initialization ## 🔧 Development ### Project Structure -The project consists of two main binaries: +The project consists of a single binary with a shared library: -1. **records**: Primary analysis tool using JSON data -2. **club**: Alternative tool scraping topdrives.club +1. **records**: Primary analysis tool using JSON data (`src/records.rs`) +2. **lib**: Shared library with track name translations (`src/lib.rs`) + +### Key Constants + +- `MAX_RQ: u8 = 113` — Maximum RQ level used in analysis (defined in `records.rs`) ### Key Data Structures @@ -232,9 +274,8 @@ cargo build # Release build cargo build --release -# Run specific binary +# Run the analyzer cargo run --bin records -cargo run --bin club ``` ## 🤝 Contributing @@ -252,6 +293,8 @@ Track definitions are stored in `tracks_repo.json`. Each track includes: - Supported conditions - Track type classification +Track name mappings are stored in `track_names.json` and loaded at compile time. + ### Adding Car Data New car performance data should be submitted to the upstream repository following their contribution guidelines. @@ -272,4 +315,4 @@ The tool fetches fresh data each time you run the `fetch` command. Keep your loc --- -*Built with ❤️ for the Top Drives community* \ No newline at end of file +*Built with ❤️ for the Top Drives community* diff --git a/docs/API.md b/docs/API.md index 016fca5..97222b7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -23,7 +23,7 @@ async fn main() **Purpose**: Command-line interface and operation dispatcher **Arguments**: Command line arguments -- `args[1]`: Command (`fetch`, `best`, `missing`, `trash`, `details`, `track`) +- `args[1]`: Command (`fetch`, `best`, `missing`, `trash`, `details`, `track`, `full_script`) **Commands**: - `fetch`: Download all data files @@ -32,6 +32,7 @@ async fn main() - `trash`: Show removable cars - `details`: Export all timing data - `track`: Track-specific analysis +- `full_script`: Run fetch + all analysis commands **Example**: ```bash @@ -148,7 +149,7 @@ async fn parse_all_times() **Purpose**: Coordinates parallel processing of car timing files -**Concurrency**: One async task per car file +**Concurrency**: One async task per car file, bounded by a semaphore (limit 20) **Synchronization**: Waits for all tasks to complete ##### `update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord)` @@ -161,12 +162,12 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa **Purpose**: Implements core best-car selection algorithm **RQ Range Analysis**: -- Tests car from base RQ up to maximum (115) +- Tests car from base RQ up to `MAX_RQ` (113) - Compares against existing best times - Updates records for improved performance **Logic**: -1. For each RQ level from car_rq to 115: +1. For each RQ level from car_rq to `MAX_RQ` (113): 2. If new time is better → replace record 3. If new time ties → add to list (if not duplicate) 4. If new time is worse → ignore @@ -205,7 +206,7 @@ async fn fetch_times() **Purpose**: Batch download of car timing data -**Rate Limiting**: 20 concurrent downloads maximum +**Rate Limiting**: 20 concurrent downloads maximum (semaphore) **Progress**: Shows download progress for each file ##### `fetch_car(rid: String)` @@ -258,6 +259,10 @@ async fn get_file(file_url: &str, local_file_path: &str) - Error handling with graceful failure - Progress indication through errors +### Shared Library (`src/lib.rs`) + +Common utilities shared across the project. + ##### `translate_track_name(track_code: &str) -> &str` **Converts track codes to human-readable names** @@ -267,7 +272,9 @@ fn translate_track_name(track_code: &str) -> &str **Purpose**: User-friendly track name display -**Coverage**: 200+ track mappings +**Implementation**: Loads mappings from `track_names.json` at compile time via `include_str!` + +**Coverage**: All tracks defined in `track_names.json` **Fallback**: Returns original code if not found ##### `translate_track_type(track_code: &str) -> &str` @@ -299,89 +306,6 @@ fn split_prefix_postfix<'a>(input: &'a str, delimiter: &'a str) -> Option<(&'a s split_prefix_postfix("laguna_a00", "_a") // Some(("laguna", "00")) ``` -### Club Module (`src/club.rs`) - -Alternative data source using web scraping from topdrives.club. - -#### Public Functions - -##### `main()` -**Entry point for club scraper** - -```rust -#[tokio::main] -async fn main() -``` - -**Purpose**: Web scraping interface for live data - -**Arguments**: -- Command line arguments for RQ and track filtering -- `-d` flag for detailed output - -##### `get_page(url: &str) -> String` -**Fetches HTML content from URLs** - -```rust -async fn get_page(url: &str) -> String -``` - -**Purpose**: Downloads web pages for scraping - -**Security**: Accepts invalid SSL certificates for compatibility - -##### `parse_best_car(text: String) -> Car` -**Extracts car data from HTML content** - -```rust -fn parse_best_car(text: String) -> Car -``` - -**Purpose**: Parses HTML tables to extract car information - -**CSS Selectors**: -- `.carList__item-manufacturer`: Car brand -- `.carList__item-model`: Car model -- `.carList__item-year`: Model year -- `.carList__item-level`: Class and RQ (complex parsing) -- `.carList__item-upgrade`: Upgrade configuration - -##### `parse_tracks(file_name: &str) -> HashMap` -**Loads track definitions from file** - -```rust -fn parse_tracks(file_name: &str) -> HashMap -``` - -**Purpose**: Creates track ID to name mappings - -**Format**: Text file with `ID NAME` format per line - -##### `get_best_car(track: u16, track_name: String, max_level: u16, detailed: bool) -> String` -**Scrapes best car data for specific track** - -```rust -async fn get_best_car(track: u16, track_name: String, max_level: u16, detailed: bool) -> String -``` - -**Purpose**: Fetches and parses best car from topdrives.club - -**API Integration**: -- Constructs topdrives.club API URLs -- Parses HTML response tables -- Formats output based on detail level - -##### `parse_argument(args: &Vec, index: usize, min: u16, max: u16) -> u16` -**Validates command line arguments** - -```rust -fn parse_argument(args: &Vec, index: usize, min: u16, max: u16) -> u16 -``` - -**Purpose**: Input validation with range checking - -**Panics**: On invalid values with descriptive messages - ## Data Structures ### Records Module Data Types @@ -436,22 +360,15 @@ type TripletKey = (String, u8, String); // Track ID RQ Level Surface Condition ``` -### Club Module Data Types - -#### `Car` -**Scraped car data from website** +### Constants +#### `MAX_RQ` ```rust -struct Car { - class: String, // Class letter (S, A, B, etc.) - rq: u8, // Rarity level - manufacturer: String, // Brand name - model: String, // Model name - year: u16, // Model year - upgrade: String, // Upgrade config -} +const MAX_RQ: u8 = 113; ``` +**Purpose**: Maximum RQ level for range analysis. Replaces former magic number 115 with a named constant. + ## Global State ### `BEST_CARS` Cache @@ -533,7 +450,7 @@ fetch_times().await; - **Per Car**: ~1-2KB metadata + timing data ### Concurrency -- **Downloads**: 20 concurrent connections max +- **Downloads**: 20 concurrent connections max (semaphore bounded) - **Processing**: One task per car file - **Thread Safety**: Mutex-protected shared state @@ -544,41 +461,16 @@ fetch_times().await; 2. Implement analysis function 3. Update help documentation -### Adding New Data Sources -1. Implement new module (similar to `club.rs`) -2. Add data source selection logic -3. Update configuration system +### Adding New Tracks +1. Update `tracks_repo.json` with new track definitions +2. Add track name mapping to `track_names.json` +3. Track names are compiled in at build time ### Custom Analysis Types 1. Create new output formatting functions 2. Add aggregation algorithms 3. Extend data structures as needed -## Testing - -### Unit Tests -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_parse_car_times() { - // Test implementation - } -} -``` - -### Integration Tests -- Full workflow testing -- File I/O validation -- Network request mocking - -### Benchmarking -- Parse performance measurement -- Memory usage profiling -- Concurrent operation testing - ## Security Considerations ### Network Security @@ -643,12 +535,14 @@ cargo flamegraph --bin records -- best - **Future**: Semantic versioning maintained ### Breaking Changes -- None currently planned -- Advance notice provided for API changes -- Deprecation warnings for removed features +- Club module (`src/club.rs`) has been removed +- `scraper` dependency removed +- `tracks.txt` removed (replaced by `track_names.json`) +- Magic number 115 replaced with `const MAX_RQ: u8 = 113` +- `translate_track_name()` now loads from `track_names.json` instead of hardcoded match --- **API Version**: 0.2.0 -**Last Updated**: 2024-12-XX -**Compatibility**: Rust 1.70.0+ \ No newline at end of file +**Last Updated**: 2026-05-06 +**Compatibility**: Rust 1.70.0+ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1eea0cd..3fc75be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## System Overview -The Top Drives Records Analyzer consists of two main components working together to provide comprehensive car performance analysis for the Top Drives racing game. +The Top Drives Records Analyzer consists of a main analysis binary and a shared library working together to provide comprehensive car performance analysis for the Top Drives racing game. ## Component Architecture @@ -16,6 +16,9 @@ The Top Drives Records Analyzer consists of two main components working together - **Performance Analysis**: Compares timing data across RQ ranges and track conditions - **Collection Management**: Identifies owned cars and optimization opportunities +#### Key Constants +- `MAX_RQ: u8 = 113` — Maximum RQ level used for range analysis (replaces former magic number 115) + #### Data Flow ``` @@ -32,28 +35,17 @@ User Collection (my_cars.json) Output: CSV/Terminal Results ``` -### 2. Club Module (`src/club.rs`) +### 2. Shared Library (`src/lib.rs`) -**Alternative data source** that scrapes live data from topdrives.club website. +**Common utilities** shared across the project. #### Key Responsibilities -- **Web Scraping**: Fetches HTML content from topdrives.club -- **HTML Parsing**: Extracts car performance data using CSS selectors -- **Real-time Data**: Provides fresh data without pre-downloaded files +- **Track Name Translation**: Converts track codes to human-readable names via `translate_track_name()` +- **Track Type Translation**: Converts condition codes to surface descriptions via `translate_track_type()` -#### Data Flow - -``` -topdrives.club Website - ↓ -HTML Content - ↓ -CSS Selector Parsing - ↓ -Car Data Extraction - ↓ -Output: Terminal Results -``` +#### Configuration +- Track name mappings are loaded from `track_names.json` at compile time using `include_str!` +- This external config file maps track codes (e.g., `"laguna"`) to human-readable names (e.g., `"Laguna Seca"`) ## Data Structures @@ -69,7 +61,7 @@ type TripletKey = (String, u8, String); **Purpose**: Composite key for uniquely identifying track conditions **Usage**: HashMap key for storing best car records -#### Car (Records Module) +#### Car ```rust struct Car { rid: String, // Repository ID @@ -97,18 +89,6 @@ struct BestCarRecord { } ``` -#### Car (Club Module) -```rust -struct Car { - class: String, // Class letter (S, A, B, etc.) - rq: u8, // Rarity level - manufacturer: String, // Brand - model: String, // Model - year: u16, // Year - upgrade: String, // Upgrade config -} -``` - ## Global State Management ### BEST_CARS Cache @@ -127,7 +107,7 @@ static BEST_CARS: Lazy>>> The system analyzes car performance across multiple RQ brackets: 1. **Base RQ**: Car's natural rarity level -2. **RQ Range**: Tests from car's RQ up to maximum (115) +2. **RQ Range**: Tests from car's RQ up to `MAX_RQ` (113) 3. **Cross-RQ Competition**: Allows cars to compete above their base level **Example**: A RQ 85 car might perform well at RQ 90-95 levels against higher-rated cars @@ -142,9 +122,9 @@ Multiple validation layers ensure data quality: ### Concurrent Processing -#### Records Module +#### Bounded Concurrency Pattern - **Task Spawning**: One async task per car file -- **Rate Limiting**: Semaphore limits concurrent downloads (20 max) +- **Semaphore**: Limits concurrent operations to 20 (bounded concurrency) - **Error Isolation**: Individual car failures don't stop batch processing #### Synchronization @@ -157,6 +137,7 @@ Multiple validation layers ensure data quality: ``` ├── cars_repo.json # Car database from repository ├── tracks_repo.json # Track definitions +├── track_names.json # Track code → name mappings (compiled in) ├── my_cars.json # User's car collection (game export) └── times/ # Individual car timing files ├── Ferrari_F40_1987.json @@ -167,8 +148,13 @@ Multiple validation layers ensure data quality: ### Output Files ``` ├── best.csv # Best cars analysis results -├── missing.csv # Missing recommended cars -└── details.csv # Detailed timing data +├── details.csv # Detailed timing data +├── add.csv # Cars to add to wishlist +├── remove.csv # Cars to remove from wishlist +├── trash.csv # Owned cars safe to remove +├── sorted.csv # Best cars sorted by count +├── very_best.csv # Best cars with count ≥ 10 +└── wishlist.csv # Missing cars already on wishlist ``` ## Performance Characteristics @@ -178,12 +164,7 @@ Multiple validation layers ensure data quality: - **Memory Usage**: Moderate (loads all timing data into memory) - **Accuracy**: High (comprehensive dataset) - **Update Frequency**: Manual refresh required - -### Club Module -- **Startup Time**: Slow (web scraping required) -- **Memory Usage**: Low (processes one track at a time) -- **Accuracy**: Variable (depends on website structure) -- **Update Frequency**: Real-time on each run +- **Concurrency**: Bounded at 20 simultaneous operations via semaphore ## Error Handling Strategy @@ -195,13 +176,12 @@ Multiple validation layers ensure data quality: ### User Feedback - **Progress Indication**: Shows fetching/download progress - **Error Messages**: Clear descriptions of what went wrong -- **Fallback Options**: Multiple data sources available ## Extension Points ### Adding New Tracks 1. Update `tracks_repo.json` with new track definitions -2. Add track name mappings in `translate_track_name()` +2. Add track name mapping in `track_names.json` 3. Add condition mappings in `translate_track_type()` ### Adding New Analysis Types @@ -209,17 +189,12 @@ Multiple validation layers ensure data quality: 2. Add command parsing in main function 3. Update help text and documentation -### Custom Data Sources -1. Implement new scraping module (similar to club.rs) -2. Add data source selection logic -3. Update configuration system - ## Security Considerations ### Network Access - **HTTPS Only**: All external requests use HTTPS - **Certificate Validation**: Accepts invalid certificates for compatibility -- **Rate Limiting**: Respects server limits with controlled concurrency +- **Rate Limiting**: Respects server limits with controlled concurrency (semaphore limit of 20) ### Local File Access - **Read Only**: No modification of game files @@ -229,7 +204,7 @@ Multiple validation layers ensure data quality: ## Future Enhancements ### Potential Improvements -1. **Caching Layer**: Cache web scraping results +1. **Caching Layer**: Cache parsed results between runs 2. **Database Backend**: Replace in-memory HashMap with persistent storage 3. **Configuration Files**: External configuration for analysis parameters 4. **GUI Interface**: Web-based interface for non-technical users @@ -239,4 +214,4 @@ Multiple validation layers ensure data quality: 1. **Pagination**: Handle large datasets with pagination 2. **Incremental Updates**: Only fetch changed data 3. **Compression**: Compress large timing files -4. **Streaming**: Process data without loading everything into memory \ No newline at end of file +4. **Streaming**: Process data without loading everything into memory diff --git a/docs/DATA_STRUCTURES.md b/docs/DATA_STRUCTURES.md index 500d707..0669458 100644 --- a/docs/DATA_STRUCTURES.md +++ b/docs/DATA_STRUCTURES.md @@ -175,6 +175,48 @@ This document describes the JSON data structures used by the Top Drives Records - Used for track-specific analysis queries - Supports condition filtering +### 5. Track Names Configuration (`track_names.json`) + +**Purpose**: Maps internal track codes to human-readable track names. + +**Source**: Included in the repository, loaded at compile time via `include_str!` + +**Structure**: +```json +{ + "laguna": "Laguna Seca", + "monaco": "Monaco Streets", + "testBowl": "Test Bowl", + "dealsGap": "Deals Gap" +} +``` + +**Field Descriptions**: + +| Field | Type | Description | +|-------|------|-------------| +| *key* | String | Internal track code (e.g., `"laguna"`) | +| *value* | String | Human-readable track name (e.g., `"Laguna Seca"`) | + +**Usage in Application**: +- Loaded by `translate_track_name()` in `src/lib.rs` +- Compiled into the binary at build time +- Fallback: Returns original track code if no mapping found + +## Application Constants + +### `MAX_RQ` + +```rust +const MAX_RQ: u8 = 113; +``` + +**Purpose**: Defines the maximum RQ level used in analysis range calculations. + +**Usage**: When evaluating car performance, each car is tested from its base RQ up to `MAX_RQ`. This replaces the former magic number 115, providing a named constant for clarity and easy adjustment. + +**Location**: Defined in `src/records.rs` + ## Data Processing Pipeline ### 1. Data Acquisition Phase @@ -226,6 +268,11 @@ if let Some(configurations) = json_data["data"].as_object() { - Process: Split on `"_a"` delimiter - Output: `("laguna", "00")` (track name, condition code) +**Track Name Translation:** +- Track codes are looked up in `track_names.json` (compiled into binary) +- Example: `"laguna"` → `"Laguna Seca"` +- Fallback: Unknown codes returned as-is + **Condition Code Translation:** - `"00"` → `"Dry"` - `"01"` → `"Wet"` @@ -255,7 +302,7 @@ BestCarRecord { ``` **RQ Range Analysis:** -- Test each car from its base RQ up to 115 +- Test each car from its base RQ up to `MAX_RQ` (113) - Compare against existing best times - Update records if better performance found @@ -303,7 +350,7 @@ BestCarRecord { - User collection: Small, loaded once **Optimization Strategies**: -- Concurrent processing of car files +- Concurrent processing of car files with bounded concurrency (semaphore limit of 20) - Rate-limited downloads (20 concurrent max) - Streaming JSON parsing where possible @@ -315,7 +362,7 @@ BestCarRecord { 3. **HashMap Operations**: Frequent insertions with RQ range checking **Optimizations Applied**: -1. **Concurrency**: One task per car file +1. **Bounded Concurrency**: Semaphore limits concurrent operations to 20 2. **Connection Pooling**: Reuse HTTP connections 3. **Early Filtering**: Invalid times rejected quickly @@ -361,8 +408,8 @@ BestCarRecord { **Track Name Issues**: - **Symptom**: Unknown track codes in output -- **Cause**: Outdated track definitions -- **Solution**: Update `tracks_repo.json` +- **Cause**: Missing mapping in `track_names.json` +- **Solution**: Add new mapping to `track_names.json` ### Data Validation Tools @@ -404,4 +451,4 @@ grep -r "No RID" times/ - Historical performance trends - Meta change impact analysis - Community submission quality metrics -- Regional performance differences \ No newline at end of file +- Regional performance differences diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index 8230b24..e43cca3 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -168,6 +168,7 @@ serde_json = "1.0.140" - Parse `my_cars.json` (user collection) - Parse individual car timing files - Parse `tracks_repo.json` (track definitions) +- Parse `track_names.json` (track name mappings) **Performance**: Zero-copy parsing where possible @@ -205,26 +206,9 @@ once_cell = "1.20.2" - Thread-safe access during concurrent parsing - Avoid expensive computation on every access -#### 6. `scraper` - HTML Parsing (Club Module Only) -```toml -scraper = "0.23.1" -``` - -**Purpose**: HTML parsing and CSS selector engine for web scraping - -**Features Used**: -- CSS selector parsing with `Selector::parse()` -- HTML document parsing with `Html::parse_document()` -- Element selection and text extraction - -**Why Needed**: -- Alternative data source for club.rs module -- Parse car data from topdrives.club HTML tables -- Extract performance information from web pages - ### Development Dependencies -#### 7. Build Tools and Quality +#### 6. Build Tools and Quality **Recommended additions for development:** ```toml @@ -289,9 +273,9 @@ name = "top_drives" version = "0.2.0" edition = "2021" -[[bin]] -name = "club" -path = "src/club.rs" +[lib] +name = "top_drives" +path = "src/lib.rs" [[bin]] name = "records" @@ -300,7 +284,6 @@ path = "src/records.rs" **Binary Configuration:** - **records**: Main analysis tool (JSON-based) -- **club**: Alternative scraper (web-based) **Dependency Versions:** - All dependencies pinned to specific versions for reproducibility @@ -342,7 +325,7 @@ RUST_BACKTRACE=1 cargo run ``` **Concurrency Tuning:** -- Default: 20 concurrent downloads (configurable via Semaphore) +- Default: 20 concurrent operations (bounded via Semaphore) - Thread count: Automatic based on CPU cores - Memory usage: ~100-200MB for full dataset @@ -471,7 +454,6 @@ cargo build --release **Binary Location:** ``` target/release/records # Main analyzer -target/release/club # Club scraper ``` **Cross-Platform Building:** @@ -529,4 +511,4 @@ cargo profdata # Profile-guided optimization - **Build Tool**: Cargo (included with Rust) - **Editor**: VS Code with rust-analyzer (recommended) - **Testing**: Built-in Rust testing framework -- **Debugging**: GDB/LLDB integration available \ No newline at end of file +- **Debugging**: GDB/LLDB integration available diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bbdf88f..2981d68 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -160,8 +160,8 @@ Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with tim ``` **Track name format:** -- Use internal names: `laguna`, `monaco`, `testBowl` -- Full mappings in `translate_track_name()` function +- Use display names: `Laguna Seca`, `Monaco`, `Test Bowl` +- Full mappings loaded from `track_names.json` **Condition options:** - `Dry` - Standard asphalt @@ -169,6 +169,43 @@ Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with tim - `Dirt` - Off-road tracks - `Snow` - Winter conditions +## Daily Workflow + +The recommended daily workflow uses the `full_script` command, which runs `fetch` followed by all analysis commands in a single pipeline: + +```bash +cargo run --bin records -- full_script +``` + +### What `full_script` Does + +1. **Fetches** fresh data from the community repository +2. **Runs** all analysis commands sequentially +3. **Generates** all output CSV files + +### Output Files + +| File | Purpose | Format | +|------|---------|--------| +| `best.csv` | Ranked list of best cars — evaluate if a car is worth keeping | `RQ,Brand,Model,Year,Count` | +| `details.csv` | Detailed timing data for all tracks/conditions — find best config for a car | `RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time` | +| `add.csv` | Cars to add to wishlist — missing cars not yet wishlisted, prefixed with `+` | `+RQ,Brand,Model,Year` | +| `remove.csv` | Cars to remove from wishlist — stale wishlist entries, prefixed with `-` | `-RQ,Brand,Model,Year` | +| `trash.csv` | Owned cars that can be safely removed | `RQ,Brand,Model,Year` | +| `sorted.csv` | Best cars sorted by count (descending) | `RQ,Brand,Model,Year,Count` | +| `very_best.csv` | Best cars with count ≥ 10 | `RQ,Brand,Model,Year,Count` | +| `wishlist.csv` | Missing cars already on wishlist | `RQ,Brand,Model,Year,Count` | + +### Typical Daily Routine + +1. **Run `full_script`** to get fresh data and full analysis +2. **Review `best.csv`** to see how your cars rank overall +3. **Check `add.csv`** for cars worth adding to your wishlist (prefixed with `+`) +4. **Check `remove.csv`** for wishlist entries to remove (prefixed with `-`) +5. **Review `trash.csv`** for owned cars you can safely sell +6. **Use `details.csv`** to find the optimal tune config for a specific car +7. **Check `very_best.csv`** for the absolute top performers (count ≥ 10) + ## Advanced Usage ### RQ Range Analysis @@ -241,16 +278,24 @@ The tool generates CSV files for external analysis: - Import into spreadsheet for sorting/filtering - Track performance across time -#### missing.csv -- Cars to acquire -- Prioritize by count and RQ relevance -- Plan acquisition strategy - #### details.csv - Comprehensive timing database - Advanced analysis and research - Historical performance tracking +#### add.csv / remove.csv +- Wishlist management +- `+` prefix indicates cars to add +- `-` prefix indicates cars to remove + +#### trash.csv +- Cars safe to sell from your collection +- Cross-reference with your garage before selling + +#### sorted.csv / very_best.csv +- Sorted rankings by versatility +- `very_best.csv` filters to count ≥ 10 for top-tier recommendations + ### Spreadsheet Analysis Tips **Excel/Google Sheets setup:** @@ -265,7 +310,7 @@ The tool generates CSV files for external analysis: #### "Please provide a command" **Problem:** No arguments provided -**Solution:** Use `best`, `missing`, `trash`, `details`, or `track` +**Solution:** Use `best`, `missing`, `trash`, `details`, `track`, or `full_script` #### "Could not read my cars file" **Problem:** `my_cars.json` not found or not readable @@ -303,9 +348,9 @@ The tool generates CSV files for external analysis: ## Tips and Best Practices ### Regular Maintenance -1. **Weekly**: Run `fetch` to get latest data -2. **Monthly**: Review `missing` cars for new acquisitions -3. **Quarterly**: Use `trash` to optimize collection +1. **Daily**: Run `full_script` to get latest data and full analysis +2. **Weekly**: Review `missing` cars for new acquisitions +3. **Monthly**: Use `trash` to optimize collection ### Data-Driven Decisions 1. **Trust the numbers**: Count-based recommendations are data-driven @@ -331,4 +376,4 @@ The tool generates CSV files for external analysis: --- -**Remember**: This tool provides data-driven insights, but ultimately you're the best judge of which cars you enjoy driving. Use the analysis as a guide, not a strict rulebook. \ No newline at end of file +**Remember**: This tool provides data-driven insights, but ultimately you're the best judge of which cars you enjoy driving. Use the analysis as a guide, not a strict rulebook. diff --git a/src/club.rs b/src/club.rs deleted file mode 100644 index 8cbce55..0000000 --- a/src/club.rs +++ /dev/null @@ -1,661 +0,0 @@ -//! # Top Drives Club Scraper -//! -//! Alternative data source for the Top Drives Records Analyzer that scrapes car -//! performance data from the topdrives.club website. This provides an alternative -//! to the JSON-based records system for fetching best car information. -//! -//! ## Purpose -//! -//! While the main `records.rs` module uses pre-downloaded JSON timing files, -//! this module can fetch fresh data directly from the topdrives.club website -//! by scraping HTML tables and parsing car information. -//! -//! ## Features -//! -//! - Scrapes best car data from topdrives.club -//! - Supports track-specific car recommendations -//! - Configurable RQ ranges and track filtering -//! - HTML parsing with fallback error handling -//! -//! ## Dependencies -//! -//! - `reqwest`: HTTP client for web scraping -//! - `scraper`: HTML parsing and CSS selector library -//! - `tokio`: Asynchronous runtime for web requests - -use std::collections::HashMap; -use std::env; -use std::fs::read_to_string; -use std::str::FromStr; - -use tokio; - -use scraper::{Html, Selector}; - -/// Represents a car entry scraped from the topdrives.club website -/// -/// Contains the essential car information needed for performance analysis, -/// parsed from the HTML tables on the topdrives.club best cars pages. -struct Car { - /// Car class letter (A, B, C, D, E, S) - class: String, - /// Rarity level (10-113) - rq: u8, - /// Car manufacturer/brand name - manufacturer: String, - /// Car model name - model: String, - /// Car model year - year: u16, - /// Upgrade configuration string (e.g., "333", "323") - upgrade: String, -} -/// Fetches HTML content from a URL with error handling -/// -/// Downloads the complete HTML content from the specified URL. Uses a custom -/// HTTP client that accepts invalid SSL certificates to handle potential -/// certificate issues with the target website. -/// -/// # Arguments -/// -/// * `url` - The URL to fetch HTML content from -/// -/// # Returns -/// -/// Returns the HTML content as a String, or an empty string if the request fails. -/// Failures are logged to the console but don't cause the program to panic. -/// -/// # Security Note -/// -/// Uses `danger_accept_invalid_certs(true)` to handle websites with certificate -/// issues. This should only be used with trusted sources like topdrives.club. -/// -/// # Error Handling -/// -/// Returns empty string on any network or parsing error, allowing the calling -/// code to handle the absence of data gracefully. -async fn get_page(url: &str) -> String { - match reqwest::Client::builder() - .danger_accept_invalid_certs(true) - .build() - .unwrap() - .get(url) - .send() - .await - { - Ok(response) => match response.text().await { - Ok(text) => text, - Err(error) => { - println!("Error getting content! Error: {}", error); - String::from("") - } - }, - Err(error) => { - println!("Error getting content! Error: {}", error); - String::from("") - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Creates mock HTML for testing car parsing - fn create_mock_car_html() -> String { - r#" -
Ferrari
-
F40
-
1987
-
- S - 95 -
-
323
- "# - .to_string() - } - - /// Creates mock HTML with missing elements - fn create_incomplete_car_html() -> String { - r#" -
McLaren
-
F1
-
1993
- "# - .to_string() - } - - /// Creates mock tracks file content - fn create_mock_tracks_content() -> String { - "1 Laguna Seca\n2 Monaco Streets\n3 Test Bowl\n".to_string() - } - - #[test] - fn test_parse_best_car_complete() { - let html = create_mock_car_html(); - let car = parse_best_car(html); - - assert_eq!(car.manufacturer, "Ferrari"); - assert_eq!(car.model, "F40"); - assert_eq!(car.year, 1987); - assert_eq!(car.class, "S"); - assert_eq!(car.rq, 95); - assert_eq!(car.upgrade, "323"); - } - - #[test] - fn test_parse_best_car_incomplete() { - let html = create_incomplete_car_html(); - let car = parse_best_car(html); - - // Should use default values for missing fields - assert_eq!(car.manufacturer, "McLaren"); - assert_eq!(car.model, "F1"); - assert_eq!(car.year, 1993); - assert_eq!(car.class, "Z"); // Default value - assert_eq!(car.rq, 0); // Default value - assert_eq!(car.upgrade, "000"); // Default value - } - - #[test] - fn test_parse_best_car_empty() { - let html = String::new(); - let car = parse_best_car(html); - - // Should use all default values - assert_eq!(car.class, "Z"); - assert_eq!(car.rq, 0); - assert_eq!(car.manufacturer, ""); - assert_eq!(car.model, ""); - assert_eq!(car.year, 1900); - assert_eq!(car.upgrade, "000"); - } - - #[test] - fn test_parse_tracks() { - let content = create_mock_tracks_content(); - let tracks = parse_tracks_from_string(content); - - assert_eq!(tracks.len(), 3); - assert_eq!(tracks.get(&1), Some(&"Laguna Seca".to_string())); - assert_eq!(tracks.get(&2), Some(&"Monaco Streets".to_string())); - assert_eq!(tracks.get(&3), Some(&"Test Bowl".to_string())); - assert_eq!(tracks.get(&99), None); - } - - #[test] - fn test_parse_tracks_empty() { - let content = String::new(); - let tracks = parse_tracks_from_string(content); - assert_eq!(tracks.len(), 0); - } - - #[test] - fn test_parse_argument_valid() { - let args = vec!["program".to_string(), "85".to_string()]; - let result = parse_argument(&args, 1, 10, 113); - assert_eq!(result, 85); - } - - #[test] - #[should_panic(expected = "Argument 1 is out of range")] - fn test_parse_argument_too_low() { - let args = vec!["program".to_string(), "5".to_string()]; - parse_argument(&args, 1, 10, 113); - } - - #[test] - #[should_panic(expected = "Argument 1 is out of range")] - fn test_parse_argument_too_high() { - let args = vec!["program".to_string(), "150".to_string()]; - parse_argument(&args, 1, 10, 113); - } - - #[test] - #[should_panic(expected = "Argument 1 could not be parsed")] - fn test_parse_argument_invalid() { - let args = vec!["program".to_string(), "invalid".to_string()]; - parse_argument(&args, 1, 10, 113); - } - - #[test] - fn test_parse_argument_boundary_values() { - let args_low = vec!["program".to_string(), "10".to_string()]; - let result_low = parse_argument(&args_low, 1, 10, 113); - assert_eq!(result_low, 10); - - let args_high = vec!["program".to_string(), "113".to_string()]; - let result_high = parse_argument(&args_high, 1, 10, 113); - assert_eq!(result_high, 113); - } - - #[tokio::test] - async fn test_get_best_car_url_construction() { - // Test that URL construction follows expected pattern - // Note: We can't easily test the actual HTTP request without mocking - let track = 1; - let track_name = "Laguna Seca".to_string(); - let max_level = 85; - let detailed = false; - - // This would be the expected URL format (for documentation purposes) - let _expected_url_pattern = "https://topdrives.club/ajax/load-best-vehicles"; - - // In a real test environment, we'd mock the HTTP request - // For now, we verify the function doesn't panic with valid inputs - let result = get_best_car(track, track_name, max_level, detailed).await; - // Result may be empty due to network requirements, but shouldn't panic - assert!(result.is_empty() || result.len() > 0); - } - - #[test] - fn test_car_struct_creation() { - let car = Car { - class: "S".to_string(), - rq: 95, - manufacturer: "Ferrari".to_string(), - model: "F40".to_string(), - year: 1987, - upgrade: "323".to_string(), - }; - - assert_eq!(car.class, "S"); - assert_eq!(car.rq, 95); - assert_eq!(car.manufacturer, "Ferrari"); - assert_eq!(car.model, "F40"); - assert_eq!(car.year, 1987); - assert_eq!(car.upgrade, "323"); - } - - #[test] - fn test_parse_tracks_malformed_lines() { - // Test handling of malformed track file lines - let content = "1 Laguna Seca\ninvalid line\n2 Monaco Streets\n".to_string(); - let tracks = parse_tracks_from_string(content); - - // Should only parse valid lines - assert_eq!(tracks.len(), 2); - assert_eq!(tracks.get(&1), Some(&"Laguna Seca".to_string())); - assert_eq!(tracks.get(&2), Some(&"Monaco Streets".to_string())); - } - - /// Helper function to test parse_tracks without file I/O - fn parse_tracks_from_string(content: String) -> HashMap { - let mut result: HashMap = HashMap::new(); - - for line in content.lines() { - if let Some((index, track)) = line.split_once(' ') { - if let Ok(id) = index.parse::() { - result.insert(id, String::from(track)); - } - } - } - - result - } -} - -/// Parses HTML content to extract the best car information -/// -/// Uses CSS selectors to extract car details from the HTML table structure -/// used by topdrives.club. The function parses manufacturer, model, year, -/// class, RQ level, and upgrade configuration from the page content. -/// -/// # Arguments -/// -/// * `text` - The HTML content containing the car information table -/// -/// # Returns -/// -/// Returns a `Car` struct populated with the parsed information. If parsing -/// fails at any step, default values are used to prevent crashes. -/// -/// # Parsing Strategy -/// -/// 1. **Manufacturer**: Extracts from `.carList__item-manufacturer` element -/// 2. **Model**: Extracts from `.carList__item-model` element -/// 3. **Year**: Extracts from `.carList__item-year` element -/// 4. **Class & RQ**: Complex parsing from `.carList__item-level` element -/// - Inner HTML contains both class letter and RQ number -/// - Uses nested HTML parsing to separate the two values -/// 5. **Upgrade**: Extracts from `.carList__item-upgrade` element -/// -/// # Error Handling -/// -/// Uses `unwrap_or` defaults for all fields to ensure the function never -/// panics due to malformed HTML or missing elements. -fn parse_best_car(text: String) -> Car { - // Initialize with default values in case parsing fails - let mut car = Car { - class: String::from("Z"), - rq: 0, - manufacturer: String::from(""), - model: String::from(""), - year: 1900, - upgrade: String::from("000"), - }; - - let document = Html::parse_document(&text); - - // Extract car manufacturer name - let manufacturer_selector = Selector::parse(".carList__item-manufacturer").unwrap(); - car.manufacturer = String::from(match document.select(&manufacturer_selector).next() { - None => "", - Some(manufacturer) => manufacturer.text().collect::>()[0], - }); - - // Extract car model name - let model_selector = Selector::parse(".carList__item-model").unwrap(); - car.model = String::from(match document.select(&model_selector).next() { - None => "", - Some(model) => model.text().collect::>()[0], - }); - - // Extract car model year - let year_selector = Selector::parse(".carList__item-year").unwrap(); - car.year = u16::from_str(match document.select(&year_selector).next() { - None => "1900", - Some(year) => year.text().collect::>()[0], - }) - .unwrap(); - - // Parse RQ level and class (more complex as they're in the same element) - let level_selector = Selector::parse(".carList__item-level").unwrap(); - let rq_raw = match document.select(&level_selector).next() { - Some(element) => element.inner_html(), - None => { - // If level element is missing, use defaults - car.class = "Z".to_string(); - car.rq = 0; - return car; - } - }; - - let rq_fragment = Html::parse_document(rq_raw.trim()); - - // Extract class letter and RQ number from nested spans - let span_selector = Selector::parse("span").unwrap(); - let mut element_iterator = rq_fragment.select(&span_selector); - let class = String::from(match element_iterator.next() { - None => "Z", - Some(class) => class.text().collect::>()[0], - }); - car.class = class.to_uppercase(); - let rq = match element_iterator.next() { - None => "1900", - Some(rq) => rq.text().collect::>()[0], - }; - car.rq = u8::from_str(rq).unwrap(); - - // Extract upgrade configuration - let upgrade_selector = Selector::parse(".carList__item-upgrade ").unwrap(); - let upgrade_raw = match document.select(&upgrade_selector).next() { - None => "", - Some(model) => model.text().collect::>()[0], - }; - car.upgrade = String::from(upgrade_raw).trim().parse().unwrap(); - - car -} - -/// Parses the tracks definition file to create track ID to name mappings -/// -/// Reads a text file containing track definitions where each line consists -/// of a track ID number followed by the track name. Creates a HashMap for -/// efficient track ID to name lookups. -/// -/// # Arguments -/// -/// * `file_name` - Path to the tracks definition file -/// -/// # Returns -/// -/// Returns a `HashMap` mapping track IDs to track names. -/// -/// # File Format -/// -/// The tracks file should have one track per line in the format: -/// ```text -/// 1 Laguna Seca -/// 2 Monaco Streets -/// 3 Test Bowl -/// ``` -/// -/// # Error Handling -/// -/// Uses `unwrap()` for simplicity - in production, proper error handling -/// should be implemented for file I/O and parsing errors. -fn parse_tracks(file_name: &str) -> HashMap { - let mut result: HashMap = HashMap::new(); - - // Process each line in the tracks file - for line in read_to_string(file_name).unwrap().lines() { - let (index, track) = line.split_once(' ').unwrap(); - result.insert(index.parse::().unwrap(), String::from(track)); - } - - result -} - -/// Main entry point for the Top Drives Club scraper -/// -/// Scrapes best car data from topdrives.club for specified tracks and RQ ranges. -/// Provides an alternative data source to the JSON-based records system. -/// -/// # Command Line Arguments -/// -/// - No args: Process all tracks with default RQ range (10-113) -/// - 1 arg: Set maximum RQ level for all tracks -/// - 2 args: Set max RQ and process single track -/// - 3 args: Set max RQ, start track, and end track for range processing -/// - `-d` flag: Enable detailed output format -/// -/// # Arguments -/// -/// * `max_rq` - Maximum RQ level to consider (default: 113) -/// * `min_track` - First track ID to process (default: 4) -/// * `max_track` - Last track ID to process (default: 278) -/// * `detailed` - Whether to show detailed output with upgrade info -/// -/// # Example Usage -/// -/// ```bash -/// cargo run --bin club # All tracks, default RQ -/// cargo run --bin club -d 100 # All tracks, RQ <= 100, detailed -/// cargo run --bin club 85 1 50 # Tracks 1-50, RQ <= 85 -/// ``` -#[tokio::main] -async fn main() { - let mut args: Vec<_> = env::args().collect(); - - let mut detailed = false; - if let Some(index_detailed) = args.iter().position(|x| *x == String::from("-d")) { - detailed = true; - args.swap_remove(index_detailed); - } - - let mut min_track = 4; - let mut max_track = 278; - - let min_rq = 10; - let mut max_rq = 113; - - match args.len() { - 1 => {} - 2 => max_rq = parse_argument(&args, 1, 10, 113), - 3 => { - max_rq = parse_argument(&args, 1, 10, 113); - min_track = parse_argument(&args, 2, 4, 278); - max_track = min_track; - } - 4 => { - max_rq = parse_argument(&args, 1, 10, 113); - min_track = parse_argument(&args, 2, 4, 278); - max_track = parse_argument(&args, 3, 4, 278); - if max_track < min_track { - let temp = min_track; - min_track = max_track; - max_track = temp - } - } - _ => panic!("Too many arguments."), - } - - let tracks = parse_tracks("tracks.txt"); - - let mut keepers: HashMap = HashMap::new(); - - for track in min_track..max_track + 1 { - let track_name = match tracks.get(&track) { - None => String::from("NO TRACK NAME"), - Some(name) => name.clone(), - }; - let best_car = get_best_car(track, track_name.clone(), max_rq, detailed).await; - if best_car.len() > 10 { - for level in min_rq..max_rq + 1 { - let best_car = get_best_car(track, track_name.clone(), level, detailed).await; - if best_car.len() > 10 { - // println!("Best Car under RQ {} for track {} ({}): {}", level, track, track_name, best_car); - keepers - .entry(best_car) - .and_modify(|counter| *counter += 1) - .or_insert(1); - } - } - } - } - - // println!("Keepers:"); - for car in keepers { - match detailed { - true => println!("{}", car.0), - false => println!("{} {}", car.0, car.1), - } - } -} - -/// Parses and validates command line arguments with range checking -/// -/// Validates that numeric arguments are within acceptable ranges and provides -/// clear error messages for invalid input. Used for RQ levels and track IDs. -/// -/// # Arguments -/// -/// * `args` - The command line arguments vector -/// * `index` - Index of the argument to parse -/// * `min` - Minimum acceptable value -/// * `max` - Maximum acceptable value -/// -/// # Returns -/// -/// Returns the parsed and validated value -/// -/// # Panics -/// -/// Panics with descriptive error messages if: -/// - The argument cannot be parsed as a number -/// - The value is outside the acceptable range -/// -/// # Error Messages -/// -/// Provides clear feedback about expected value ranges and which argument -/// position is problematic. -fn parse_argument(args: &Vec, index: usize, min: u16, max: u16) -> u16 { - match args[index].parse::() { - Ok(parsed_value) => { - if parsed_value >= min && parsed_value <= max { - parsed_value - } else { - panic!( - "Argument {} is out of range. Please provide a value between {} and {}.", - index, min, max - ); - } - } - Err(_) => { - panic!("Argument {} could not be parsed. Please provide a positive integer value between {} and {}.", index, min, max) - } - } -} - -/// Fetches and parses the best car data for a specific track from topdrives.club -/// -/// Constructs the appropriate URL for the topdrives.club API, fetches the HTML -/// response, and parses it to extract the best car information for the given -/// track and RQ constraints. -/// -/// # Arguments -/// -/// * `track` - The track ID number (1-278) -/// * `track_name` - Human-readable track name for display -/// * `max_level` - Maximum RQ level to consider -/// * `detailed` - Whether to include upgrade configuration in output -/// -/// # Returns -/// -/// Returns a formatted string containing the best car information, or an empty -/// string if the request fails or no data is available. -/// -/// # API Details -/// -/// The function constructs a URL with the following parameters: -/// - `sort_by=level&sortorder=desc`: Sort by RQ level, highest first -/// - `prize=all`: Include all cars, not just prize cars -/// - `level=10%3B{max_level}`: RQ range from 10 to max_level -/// - `acceleration=1%3B50&grip=40%3B110`: Performance filters -/// - `year=1900%3B2030`: Year range filter -/// - `seats_count=1%3B9`: Include all seat counts -/// - `track={track}`: Specific track ID -/// - `page=0`: First page of results -/// -/// # Output Formats -/// -/// **Detailed mode:** -/// ```text -/// S85 Ferrari 250 GTO 1962: Laguna Seca (333) -/// ``` -/// -/// **Normal mode:** -/// ```text -/// S85 Ferrari 250 GTO 1962 -/// ``` -async fn get_best_car(track: u16, track_name: String, max_level: u16, detailed: bool) -> String { - // Construct the API URL with all necessary parameters - let url = format!("https://topdrives.club/ajax/load-best-vehicles?sort_by=level&sortorder=desc&prize=all&level=10%3B{}&acceleration=1%3B50&grip=40%3B110&year=1900%3B2030&seats_count=1%3B9&track={}&page=0", - max_level, track); - // println!("Getting {}", url); - let text = get_page(&url).await; - - if text.len() > 10 { - let best_car = parse_best_car(text); - - match detailed { - true => { - format!( - "{}{} {} {} {}: {} ({})", - best_car.class, // Car class (S, A, B, etc.) - best_car.rq, // RQ level - best_car.manufacturer, // Brand name - best_car.model, // Model name - best_car.year, // Model year - track_name, // Track name - best_car.upgrade, // Upgrade configuration - ) - } - false => { - format!( - "{}{} {} {} {}", - best_car.class, // Car class - best_car.rq, // RQ level - best_car.manufacturer, // Brand name - best_car.model, // Model name - best_car.year, // Model year - ) - } - } - } else { - // No data received or request failed - String::from("") - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b15e1d6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,317 @@ +//! # Top Drives Shared Library +//! +//! Shared types and logic used by the `records` binary. +//! +//! ## Re-exports +//! +//! - **`Car` struct** — core car information from the records database +//! - **`BestCarRecord` struct** — best performing car record for a specific track/RQ/condition +//! - **`TripletKey` type alias** — composite key `(track_id, rq, surface)` +//! - **`AnalysisContext` struct** — holds mutable analysis state (replaces global static) +//! - **`MyCarsData` struct** — parsed user data from `my_cars.json` +//! - **`translate_track_name()`** — internal track code → human-readable name +//! - **`translate_track_type()`** — condition code → surface name +//! - **`get_file()`** — async file download utility +//! - **`load_my_cars_data()`** — loads and parses `my_cars.json` +//! - **`compare_cars()`** — sorting comparator for `Car` + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::fs::File; +use std::io::BufReader; +use std::sync::{Arc, Mutex, OnceLock}; + +use reqwest::get; +use serde_json::Value; + +/// Core car information structure containing basic car metadata. +/// Used throughout the application to represent car entities. +#[derive(Debug, Clone)] +pub struct Car { + /// Unique car identifier used in the records database + pub rid: String, + /// Car manufacturer/brand name + pub brand: String, + /// Car model name (without brand) + pub only_name: String, + /// Car model year + pub year: u16, + /// Rarity level (10-113, affects performance brackets) + pub rq: u8, + /// Whether this car is only available as a prize car + pub prize: bool, + /// Game-specific unique identifier for the car + pub guid: String, +} + +/// Represents the best performing car record for a specific track, RQ, and condition. +/// Contains all necessary information to compare car performance. +#[derive(Debug, Clone)] +pub struct BestCarRecord { + /// Car identifier from the records database + pub car_rid: String, + /// Car manufacturer/brand name + pub brand: String, + /// Car model name (without brand) + pub only_name: String, + /// Car model year + pub year: u16, + /// Rarity level of this specific car configuration + pub rq: u8, + /// Tune configuration string (e.g., "111", "233", "323") + pub config: String, + /// Track time in seconds (lower is better) + pub time: f64, + /// Whether the current user owns this car + pub owned: bool, + /// Game-specific unique identifier for the car + pub guid: String, +} + +/// Type alias for the composite key used to identify unique track conditions. +/// Consists of: (Track ID, RQ Level, Surface Condition) +pub type TripletKey = (String, u8, String); + +/// Holds the mutable analysis state for a records analysis run. +/// +/// Replaces the former global `static BEST_CARS`. Created in `main()` and +/// passed through the call chain so that no global mutable state is needed. +pub struct AnalysisContext { + /// Best car records for each track/RQ/surface combination, protected by + /// `Arc>` for concurrent access from spawned tasks. + pub best_cars: Arc>>>, +} + +impl AnalysisContext { + /// Create a new, empty analysis context. + pub fn new() -> Self { + AnalysisContext { + best_cars: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +/// Parsed user data from `my_cars.json` +/// +/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single +/// file read, avoiding duplicate I/O. +pub struct MyCarsData { + /// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`) + pub owned: HashSet, + /// GUIDs of wishlisted cars (from `wishlists[0].cards`) + pub wishlist: HashSet, +} + +/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data. +/// +/// Opens the game export file a single time and extracts: +/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`) +/// - **Wishlist** from `wishlists[0].cards` +/// +/// # File Source +/// +/// * **File**: `my_cars.json` (exported from Top Drives game) +/// +/// # Error Handling +/// +/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file +/// is missing or cannot be parsed, matching the graceful degradation pattern +/// used throughout the codebase. +pub fn load_my_cars_data() -> MyCarsData { + let file = match File::open("my_cars.json") { + Ok(f) => f, + Err(e) => { + eprintln!("Warning: could not open my_cars.json: {e}"); + return MyCarsData { + owned: HashSet::new(), + wishlist: HashSet::new(), + }; + } + }; + let reader = BufReader::new(file); + let json: Value = match serde_json::from_reader(reader) { + Ok(v) => v, + Err(e) => { + eprintln!("Warning: could not parse my_cars.json: {e}"); + return MyCarsData { + owned: HashSet::new(), + wishlist: HashSet::new(), + }; + } + }; + + // Extract owned car GUIDs from playerDeck + let owned: HashSet = json + .get("playerDeck") + .and_then(|d| d.as_array()) + .map(|deck| { + deck.iter() + .filter_map(|card| { + if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) { + card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + + // Extract wishlist GUIDs from wishlists[0].cards + let wishlist: HashSet = json + .get("wishlists") + .and_then(|w| w.get(0)) + .and_then(|w| w.get("cards")) + .and_then(|c| c.as_array()) + .map(|cards| { + cards + .iter() + .filter_map(|c| c.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + MyCarsData { owned, wishlist } +} + +/// Comparison function for sorting cars by RQ, then brand, then model name. +/// +/// Used as a shared comparator to ensure consistent ordering across output functions. +pub fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering { + a.rq.cmp(&b.rq) + .then(a.brand.cmp(&b.brand)) + .then(a.only_name.cmp(&b.only_name)) +} + +/// Downloads a file from a URL and saves it locally. +/// +/// Generic utility function for fetching files from remote URLs. Used by +/// both `get_cars_file()` and `get_tracks_file()` to download the latest +/// database files from the repository. +/// +/// # Arguments +/// +/// * `file_url` - The remote URL to download from +/// * `local_file_path` - Local file path where the downloaded content should be saved +/// +/// # Behavior +/// +/// 1. Removes any existing file at the local path +/// 2. Downloads the file from the remote URL +/// 3. Saves the content to the local file path +/// 4. Reports any errors that occur during the process +/// +/// # Error Handling +/// +/// Prints error messages for network failures but does not panic, allowing +/// the application to continue with cached data if available. +pub async fn get_file(file_url: &str, local_file_path: &str) { + // Remove existing file to ensure fresh download + let _ = fs::remove_file(local_file_path); + + match get(file_url).await { + Ok(response) => { + // Download successful, save to local file + match response.text().await { + Ok(content) => { + if let Err(e) = fs::write(local_file_path, content) { + eprintln!("Error writing {local_file_path}: {e}"); + } + } + Err(e) => eprintln!("Error reading response from {file_url}: {e}"), + } + } + Err(error) => { + // Download failed, print error but don't panic + eprintln!("Failed to fetch the file. Status: {}", error.to_string()); + } + } +} + +/// Translates track condition codes to human-readable surface condition names. +/// +/// Converts internal track condition codes (like "00", "01", "10") to descriptive +/// names that represent the actual racing surface conditions in Top Drives. +/// +/// # Arguments +/// +/// * `track_code` - The internal condition code from the timing data +/// +/// # Returns +/// +/// Returns a human-readable string describing the track surface condition: +/// - "00" → "Dry" (standard asphalt) +/// - "01" → "Wet" (rain-affected) +/// - "10" → "Dirt" (off-road conditions) +/// - And many more specialized conditions +/// +/// # Coverage +/// +/// Supports all known track condition codes used in the Top Drives game, +/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet". +pub fn translate_track_type(track_code: &str) -> &str { + match track_code { + "00" => "Dry", + "01" => "Wet", + "10" => "Dirt", + "11" => "Dirt Wet", + "20" => "Gravel", + "30" => "Ice", + "40" => "Asphalt Dirt", + "41" => "Asphalt Dirt Wet", + "50" => "Sand", + "60" => "Snow", + "70" => "Grass", + "71" => "Grass Wet", + "b0" => "Asphalt Gravel", + "c0" => "Asphalt Sand", + "c1" => "Asphalt Sand Wet", + "d0" => "Asphalt Snow", + "e0" => "Sand Dirt", + "f0" => "Asphalt Grass Dirt", + "g0" => "Ice Snow", + "h1" => "Snow Dirt Wet", + _ => track_code, + } +} + +/// Translates internal track identifiers to human-readable track names. +/// +/// Converts compact internal track codes (like "laguna", "monaco", "testBowl") +/// to their full, descriptive names that appear in the Top Drives game interface. +/// +/// # Arguments +/// +/// * `track_code` - The internal track identifier from the timing data +/// +/// # Returns +/// +/// Returns the full track name as displayed in the game: +/// - "laguna" → "Laguna Seca" +/// - "monaco" → "Monaco Streets" +/// - "testBowl" → "Test Bowl" +/// - And hundreds more track mappings +/// +/// # Coverage +/// +/// Contains mappings for all known tracks in the Top Drives game, including +/// special tracks like drag strips, rally stages, and circuit races. +/// +/// # Note +/// +/// If an unknown track code is provided, the original code is returned +/// unchanged, allowing the system to gracefully handle new tracks. +pub fn translate_track_name(track_code: &str) -> String { + static TRACK_NAMES: OnceLock> = OnceLock::new(); + + let map = TRACK_NAMES.get_or_init(|| { + let json_str = include_str!("../track_names.json"); + let raw: HashMap = + serde_json::from_str(json_str).expect("track_names.json should be valid JSON"); + raw + }); + + map.get(track_code) + .cloned() + .unwrap_or_else(|| track_code.to_string()) +} diff --git a/src/records.rs b/src/records.rs index 92efa48..6fdea26 100644 --- a/src/records.rs +++ b/src/records.rs @@ -30,71 +30,23 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::fs; -use std::fs::File; -use std::io::BufReader; -use std::sync::{Arc, LazyLock, Mutex}; +use std::sync::Arc; use tokio::sync::Semaphore; use tokio::task; use futures::future::join_all; -use reqwest::get; - use serde_json::Value; -/// Core car information structure containing basic car metadata -/// Used throughout the application to represent car entities -#[derive(Debug, Clone)] -struct Car { - /// Unique car identifier used in the records database - rid: String, - /// Car manufacturer/brand name - brand: String, - /// Car model name (without brand) - only_name: String, - /// Car model year - year: u16, - /// Rarity level (10-113, affects performance brackets) - rq: u8, - /// Whether this car is only available as a prize car - prize: bool, - /// Game-specific unique identifier for the car - guid: String, -} +use top_drives::{ + AnalysisContext, BestCarRecord, Car, TripletKey, + compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type, +}; -/// Represents the best performing car record for a specific track, RQ, and condition -/// Contains all necessary information to compare car performance -#[derive(Debug, Clone)] -struct BestCarRecord { - /// Car identifier from the records database - car_rid: String, - /// Car manufacturer/brand name - brand: String, - /// Car model name (without brand) - only_name: String, - /// Car model year - year: u16, - /// Rarity level of this specific car configuration - rq: u8, - /// Tune configuration string (e.g., "111", "233", "323") - config: String, - /// Track time in seconds (lower is better) - time: f64, - /// Whether the current user owns this car - owned: bool, - /// Game-specific unique identifier for the car - guid: String, -} -/// Type alias for the composite key used to identify unique track conditions -/// Consists of: (Track ID, RQ Level, Surface Condition) -type TripletKey = (String, u8, String); - -/// Global cache storing the best car records for each track, RQ, and surface combination -/// Protected by a Mutex for thread-safe access during concurrent parsing operations -/// Uses std::sync::LazyLock for one-time initialization -static BEST_CARS: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); +/// Maximum RQ level in the Top Drives database. +/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors. +const MAX_RQ: u8 = 113; /// Main entry point for the Top Drives Records Analyzer /// @@ -140,37 +92,42 @@ async fn main() { // Standard best cars analysis // Shows cars you own that perform well across tracks let my_data = load_my_cars_data(); - let _car_list = parse_all_times(&my_data.owned).await; - print_best_cars(false, &my_data.wishlist); // false = include owned cars + let ctx = AnalysisContext::new(); + let _car_list = parse_all_times(&my_data.owned, &ctx).await; + print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars } "missing" => { // Missing cars analysis // Shows high-performing cars you don't own // Cars not yet on the wishlist are prefixed with "+" let my_data = load_my_cars_data(); - let _car_list = parse_all_times(&my_data.owned).await; - print_best_cars(true, &my_data.wishlist); // true = filter out owned cars + let ctx = AnalysisContext::new(); + let _car_list = parse_all_times(&my_data.owned, &ctx).await; + print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars } "wishlist" => { // Wishlist review // Shows cars on the wishlist that are no longer among the best let my_data = load_my_cars_data(); - let car_list = parse_all_times(&my_data.owned).await; - print_wishlist_stale(&my_data.wishlist, &car_list); + let ctx = AnalysisContext::new(); + let car_list = parse_all_times(&my_data.owned, &ctx).await; + print_wishlist_stale(&ctx, &my_data.wishlist, &car_list); } "trash" => { // Collection optimization // Identifies cars that can be safely removed let my_data = load_my_cars_data(); - let car_list = parse_all_times(&my_data.owned).await; - print_trash(&car_list, &my_data.owned); + let ctx = AnalysisContext::new(); + let car_list = parse_all_times(&my_data.owned, &ctx).await; + print_trash(&ctx, &car_list, &my_data.owned); } "details" => { // Comprehensive data export // Outputs all timing data for external analysis let my_data = load_my_cars_data(); - parse_all_times(&my_data.owned).await; - print_best_times(); + let ctx = AnalysisContext::new(); + parse_all_times(&my_data.owned, &ctx).await; + print_best_times(&ctx); } "track" => { // Track-specific analysis @@ -180,11 +137,12 @@ async fn main() { } else { // Safe to unwrap here due to above check let my_data = load_my_cars_data(); - parse_all_times(&my_data.owned).await; + let ctx = AnalysisContext::new(); + parse_all_times(&my_data.owned, &ctx).await; let track = args.get(2).unwrap(); let condition = args.get(3).unwrap(); let rq = args.get(4).unwrap(); - print_best_car(track, condition, rq); + print_best_car(&ctx, track, condition, rq); } } &_ => { @@ -202,7 +160,10 @@ async fn main() { /// /// # Arguments /// +/// * `ctx` - Analysis context containing the best cars data /// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file +/// * `owned` - Set of GUIDs the user owns +/// /// Only considers cars that are not among the best performers and are below RQ 80 (to avoid /// removing potentially useful higher-end cars). /// @@ -216,11 +177,11 @@ async fn main() { /// # Note /// Prize cars are excluded from trash recommendations as they may have collection value /// beyond their performance characteristics. -fn print_trash(car_list: &[Car], owned: &HashSet) { +fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet) { // Extract GUIDs of all best performing cars across all tracks and conditions // Lock is scoped to avoid holding it during file I/O below let best_cars: HashSet<_> = { - let guard = BEST_CARS.lock().unwrap(); + let guard = ctx.best_cars.lock().unwrap(); guard .values() .flat_map(|records| records.iter().map(|record| record.guid.clone())) @@ -248,98 +209,6 @@ fn print_trash(car_list: &[Car], owned: &HashSet) { } } -/// Parsed user data from `my_cars.json` -/// -/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single -/// file read, avoiding duplicate I/O. -struct MyCarsData { - /// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`) - owned: HashSet, - /// GUIDs of wishlisted cars (from `wishlists[0].cards`) - wishlist: HashSet, -} - -/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data -/// -/// Opens the game export file a single time and extracts: -/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`) -/// - **Wishlist** from `wishlists[0].cards` -/// -/// # File Source -/// -/// * **File**: `my_cars.json` (exported from Top Drives game) -/// -/// # Error Handling -/// -/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file -/// is missing or cannot be parsed, matching the graceful degradation pattern -/// used throughout the codebase. -fn load_my_cars_data() -> MyCarsData { - let file = match File::open("my_cars.json") { - Ok(f) => f, - Err(e) => { - eprintln!("Warning: could not open my_cars.json: {e}"); - return MyCarsData { - owned: HashSet::new(), - wishlist: HashSet::new(), - }; - } - }; - let reader = BufReader::new(file); - let json: Value = match serde_json::from_reader(reader) { - Ok(v) => v, - Err(e) => { - eprintln!("Warning: could not parse my_cars.json: {e}"); - return MyCarsData { - owned: HashSet::new(), - wishlist: HashSet::new(), - }; - } - }; - - // Extract owned car GUIDs from playerDeck - let owned: HashSet = json - .get("playerDeck") - .and_then(|d| d.as_array()) - .map(|deck| { - deck.iter() - .filter_map(|card| { - if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) { - card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string()) - } else { - None - } - }) - .collect() - }) - .unwrap_or_default(); - - // Extract wishlist GUIDs from wishlists[0].cards - let wishlist: HashSet = json - .get("wishlists") - .and_then(|w| w.get(0)) - .and_then(|w| w.get("cards")) - .and_then(|c| c.as_array()) - .map(|cards| { - cards - .iter() - .filter_map(|c| c.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - - MyCarsData { owned, wishlist } -} - -/// Comparison function for sorting cars by RQ, then brand, then model name -/// -/// Used as a shared comparator to ensure consistent ordering across output functions. -fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering { - a.rq.cmp(&b.rq) - .then(a.brand.cmp(&b.brand)) - .then(a.only_name.cmp(&b.only_name)) -} - /// Prints cars on the wishlist that are no longer among the best performers /// /// Compares the user's wishlist (from `my_cars.json`) against the current best-car @@ -358,13 +227,14 @@ fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering { /// /// # Arguments /// +/// * `ctx` - Analysis context containing the best cars data /// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json` /// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file -fn print_wishlist_stale(wishlist: &HashSet, car_list: &[Car]) { +fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet, car_list: &[Car]) { // Collect GUIDs of all cars that are currently best for at least one combination // Lock is scoped to avoid holding it during file I/O below let current_best_guids: HashSet = { - let best_cars = BEST_CARS.lock().unwrap(); + let best_cars = ctx.best_cars.lock().unwrap(); best_cars .values() .flat_map(|records| records.iter().map(|r| r.guid.clone())) @@ -405,10 +275,10 @@ fn print_wishlist_stale(wishlist: &HashSet, car_list: &[Car]) { /// # Note /// This generates a large amount of output data and is primarily used for /// external analysis or when detailed performance data is required. -fn print_best_times() { +fn print_best_times(ctx: &AnalysisContext) { // Scope the lock to only the data extraction phase - let sorted_data: Vec<((String, u8, String), Vec)> = { - let best_cars = BEST_CARS.lock().unwrap(); + let sorted_data: Vec<(TripletKey, Vec)> = { + let best_cars = ctx.best_cars.lock().unwrap(); // Collect keys and sort them by RQ and Track ID for consistent output let mut sorted_keys: Vec<_> = best_cars.keys().collect(); @@ -445,7 +315,9 @@ fn print_best_times() { /// /// # Arguments /// +/// * `ctx` - Analysis context containing the best cars data /// * `filter_out_owned` - If true, excludes cars already owned by the user from the output +/// * `wishlist` - Set of car GUIDs on the user's wishlist /// /// # Output Format /// ```csv @@ -458,10 +330,10 @@ fn print_best_times() { /// # Note /// The count represents how many times this car achieved the best time across different /// track/RQ/condition combinations, indicating overall performance quality. -fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet) { +fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet) { // Scope the lock to only the data extraction phase let car_count: HashMap = { - let best_cars = BEST_CARS.lock().unwrap(); + let best_cars = ctx.best_cars.lock().unwrap(); let mut counts: HashMap = HashMap::new(); // Count occurrences of each car across all tracks and conditions @@ -507,6 +379,7 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet) { /// /// # Arguments /// +/// * `ctx` - Analysis context containing the best cars data /// * `track` - The track identifier (e.g., "laguna", "monaco") /// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt") /// * `rq` - RQ level limit (cars with this RQ or lower will be considered) @@ -519,11 +392,11 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet) { /// # Note /// If no car is found for the specified combination, it may indicate that no data /// is available for that specific track/condition/RQ combination. -fn print_best_car(track: &String, condition: &String, rq: &String) { - let best_cars = BEST_CARS.lock().unwrap(); +fn print_best_car(ctx: &AnalysisContext, track: &String, condition: &String, rq: &String) { + let best_cars = ctx.best_cars.lock().unwrap(); let key = ( track.clone(), - rq.parse::().unwrap_or(115), // Default to max RQ if parsing fails + rq.parse::().unwrap_or(MAX_RQ), // Default to max RQ if parsing fails condition.clone(), ); @@ -552,11 +425,13 @@ fn print_best_car(track: &String, condition: &String, rq: &String) { /// /// This is the core function responsible for processing individual car performance data. /// It reads the car's JSON file, extracts timing information for each track and condition, -/// filters out invalid or downvoted times, and updates the global best cars database. +/// filters out invalid or downvoted times, and updates the analysis context. /// /// # Arguments /// /// * `car` - The car data structure containing metadata about the car to process +/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership) +/// * `ctx` - Analysis context containing the shared best cars map /// /// # Data Processing /// @@ -565,7 +440,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) { /// 3. **Track Processing**: Extracts timing data for each track and surface condition /// 4. **Validation**: Filters out invalid times and downvoted entries /// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl) -/// 6. **Database Update**: Updates the global best cars cache with new records +/// 6. **Database Update**: Updates the analysis context with new records /// /// # Special Cases /// @@ -577,7 +452,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) { /// /// This function is designed to be called concurrently for multiple cars to improve /// overall processing speed when analyzing large car databases. -async fn parse_car_times(car: Car, my_cars: &HashSet) { +async fn parse_car_times(car: Car, my_cars: &HashSet, ctx: &AnalysisContext) { // Construct file path for this car's timing data let file_name = car.rid.to_owned() + ".json"; let local_file_path = "times/".to_owned() + file_name.as_str(); @@ -634,7 +509,8 @@ async fn parse_car_times(car: Car, my_cars: &HashSet) { // Apply quality filters before processing if time > 1.2 && no_down_votes { update_best_car( - translate_track_name(cleaned_track_id), + &ctx.best_cars, + &translate_track_name(cleaned_track_id), car.rq, translate_track_type(track_type), BestCarRecord { @@ -656,14 +532,15 @@ async fn parse_car_times(car: Car, my_cars: &HashSet) { } } } -/// Updates the global best cars database with a new performance record +/// Updates the best cars database with a new performance record /// /// Implements the core logic for determining the best performing cars across different -/// RQ ranges. For each RQ level from the car's base RQ up to the maximum (115), this +/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this /// function determines whether the new record represents an improvement or tie. /// /// # Arguments /// +/// * `best_cars` - Shared mutable reference to the best cars map /// * `track_id` - The track identifier /// * `car_rq` - The base RQ level of the car /// * `surface` - The track surface condition (Dry, Wet, etc.) @@ -671,7 +548,7 @@ async fn parse_car_times(car: Car, my_cars: &HashSet) { /// /// # Algorithm /// -/// For each RQ level from car's RQ to 115: +/// For each RQ level from car's RQ to MAX_RQ: /// 1. **Better Time**: If new record is faster than current best, replace the list /// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate) /// 3. **Worse Time**: Ignore the new record @@ -681,11 +558,20 @@ async fn parse_car_times(car: Car, my_cars: &HashSet) { /// The RQ range iteration allows cars to compete at higher RQ levels than their base /// rating, simulating how cars might perform when upgraded or in different matchups. /// This provides more comprehensive analysis across the entire performance spectrum. -fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord) { - let mut best_cars = BEST_CARS.lock().unwrap(); +fn update_best_car( + best_cars: &Arc>>>, + track_id: &str, + car_rq: u8, + surface: &str, + new_record: BestCarRecord, +) { + /// Tolerance for considering two times as equal (5ms) + const TIME_TOLERANCE: f64 = 0.005; - // Check performance across RQ range from car's base RQ up to maximum (115) - for rq in car_rq..=115 { + let mut best_cars = best_cars.lock().unwrap(); + + // Check performance across RQ range from car's base RQ up to MAX_RQ + for rq in car_rq..=MAX_RQ { let key = (track_id.to_string(), rq, surface.to_string()); match best_cars.get_mut(&key) { @@ -696,8 +582,8 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa // New record is faster than current best // Replace entire list - only keep the fastest car *existing_list = vec![new_record.clone()]; - } else if (new_record.time - best_time).abs() < f64::EPSILON { - // Times are essentially equal (within floating point precision) + } else if (new_record.time - best_time).abs() < TIME_TOLERANCE { + // Times are essentially equal (within tolerance) // This is a tie - add the car only if not already present // Prevents duplicate entries for the same car if !existing_list @@ -726,8 +612,9 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa /// /// # Performance /// -/// Uses Tokio's spawn system to create one task per car, enabling concurrent -/// file I/O and JSON parsing operations. The function waits for all tasks to +/// Uses Tokio's spawn system with a semaphore to create one task per car, +/// enabling concurrent file I/O and JSON parsing operations while limiting +/// concurrency to 20 parallel tasks. The function waits for all tasks to /// complete before returning. /// /// # Note @@ -738,18 +625,24 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa /// # Returns /// /// Returns the full car list so callers can reuse it without re-reading the file. -async fn parse_all_times(my_cars: &HashSet) -> Vec { +async fn parse_all_times(my_cars: &HashSet, ctx: &AnalysisContext) -> Vec { // Use Arc for efficient O(1) lookups across concurrent tasks let my_cars = Arc::new(my_cars.clone()); let car_list = get_car_list(); let mut handles = vec![]; + // Limit concurrent parsing to 20 to avoid excessive resource usage + let semaphore = Arc::new(Semaphore::new(20)); + // Create one async task per car for concurrent processing for car in car_list.iter() { let my_cars = Arc::clone(&my_cars); + let best_cars = Arc::clone(&ctx.best_cars); let car = car.clone(); + let sem = Arc::clone(&semaphore); let handle = task::spawn(async move { - parse_car_times(car, &my_cars).await; + let _permit = sem.acquire().await.unwrap(); + parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await; }); handles.push(handle); } @@ -908,136 +801,10 @@ async fn get_cars_file() { println!("Fetching cars file..."); get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await; } -/// Downloads a file from a URL and saves it locally -/// -/// Generic utility function for fetching files from remote URLs. Used by -/// both `get_cars_file()` and `get_tracks_file()` to download the latest -/// database files from the repository. -/// -/// # Arguments -/// -/// * `file_url` - The remote URL to download from -/// * `local_file_path` - Local file path where the downloaded content should be saved -/// -/// # Behavior -/// -/// 1. Removes any existing file at the local path -/// 2. Downloads the file from the remote URL -/// 3. Saves the content to the local file path -/// 4. Reports any errors that occur during the process -/// -/// # Error Handling -/// -/// Prints error messages for network failures but does not panic, allowing -/// the application to continue with cached data if available. -async fn get_file(file_url: &str, local_file_path: &str) { - // Remove existing file to ensure fresh download - let _ = fs::remove_file(local_file_path); - - match get(file_url).await { - Ok(response) => { - // Download successful, save to local file - fs::write(local_file_path, response.text().await.unwrap()) - .expect("Unable to write file!"); - } - Err(error) => { - // Download failed, print error but don't panic - eprintln!("Failed to fetch the file. Status: {}", error.to_string()); - } - } -} -/// Translates track condition codes to human-readable surface condition names -/// -/// Converts internal track condition codes (like "00", "01", "10") to descriptive -/// names that represent the actual racing surface conditions in Top Drives. -/// -/// # Arguments -/// -/// * `track_code` - The internal condition code from the timing data -/// -/// # Returns -/// -/// Returns a human-readable string describing the track surface condition: -/// - "00" → "Dry" (standard asphalt) -/// - "01" → "Wet" (rain-affected) -/// - "10" → "Dirt" (off-road conditions) -/// - And many more specialized conditions -/// -/// # Coverage -/// -/// Supports all known track condition codes used in the Top Drives game, -/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet". -fn translate_track_type(track_code: &str) -> &str { - match track_code { - "00" => "Dry", - "01" => "Wet", - "10" => "Dirt", - "11" => "Dirt Wet", - "20" => "Gravel", - "30" => "Ice", - "40" => "Asphalt Dirt", - "41" => "Asphalt Dirt Wet", - "50" => "Sand", - "60" => "Snow", - "70" => "Grass", - "71" => "Grass Wet", - "b0" => "Asphalt Gravel", - "c0" => "Asphalt Sand", - "c1" => "Asphalt Sand Wet", - "d0" => "Asphalt Snow", - "e0" => "Sand Dirt", - "f0" => "Asphalt Grass Dirt", - "g0" => "Ice Snow", - "h1" => "Snow Dirt Wet", - _ => track_code, - } -} #[cfg(test)] mod tests { use super::*; - use std::fs; - use tempfile::tempdir; - - /// Test data for mocking car database - fn create_test_car_data() -> Vec { - vec![ - serde_json::json!({ - "rid": "Test_Car_1", - "brand": "TestBrand", - "onlyName": "TestCar1", - "year": 2020, - "rq": 85, - "prize": false, - "guid": "test_guid_1" - }), - serde_json::json!({ - "rid": "Test_Car_2", - "brand": "TestBrand", - "onlyName": "TestCar2", - "year": 2019, - "rq": 90, - "prize": true, - "guid": "test_guid_2" - }), - ] - } - - /// Test data for mocking user collection - fn create_test_user_data() -> Value { - serde_json::json!({ - "playerDeck": [ - { - "cardId": "test_guid_1", - "locked": true - }, - { - "cardId": "test_guid_2", - "locked": false - } - ] - }) - } #[test] fn test_split_once() { @@ -1087,42 +854,9 @@ mod tests { assert_eq!(translate_track_type("99"), "99"); } - #[test] - fn test_get_car_list() { - let temp_dir = tempdir().unwrap(); - let cars_file = temp_dir.path().join("test_cars.json"); - - // Create test data file - let test_cars = create_test_car_data(); - fs::write(&cars_file, serde_json::to_string(&test_cars).unwrap()).unwrap(); - - // Mock the file read by temporarily replacing the file - // Note: In a real scenario, we'd use a more sophisticated mocking approach - // For now, we'll test the logic indirectly through other functions - } - - #[test] - fn test_get_my_car_list() { - let temp_dir = tempdir().unwrap(); - let user_file = temp_dir.path().join("test_my_cars.json"); - - // Create test user data file - let test_user_data = create_test_user_data(); - fs::write(&user_file, serde_json::to_string(&test_user_data).unwrap()).unwrap(); - - // Note: Testing this function requires file system mocking - // In a production environment, we'd use dependency injection or trait objects - } - #[test] fn test_update_best_car_logic() { - // Note: We can't easily test the global BEST_CARS mutex in unit tests - // due to potential poisoning and concurrent access issues. - // In a real testing environment, we'd use dependency injection or trait objects. - - // For now, we'll test the logic indirectly through other means - // This test serves as documentation of expected behavior - + // Verify test data is constructed correctly let test_record = BestCarRecord { car_rid: "Test_Car".to_string(), brand: "TestBrand".to_string(), @@ -1135,7 +869,6 @@ mod tests { guid: "test_guid".to_string(), }; - // Verify test data is constructed correctly assert_eq!(test_record.time, 100.0); assert_eq!(test_record.rq, 85); assert_eq!(test_record.config, "111"); @@ -1148,8 +881,7 @@ mod tests { #[test] fn test_rq_range_analysis() { - // Reset global state - *BEST_CARS.lock().unwrap() = HashMap::new(); + let ctx = AnalysisContext::new(); let test_record = BestCarRecord { car_rid: "Test_Car".to_string(), @@ -1163,13 +895,13 @@ mod tests { guid: "test_guid".to_string(), }; - // Test that car appears in RQ range from its base RQ to 115 - update_best_car("laguna", 85, "Dry", test_record); + // Test that car appears in RQ range from its base RQ to MAX_RQ + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record); - let best_cars = BEST_CARS.lock().unwrap(); + let best_cars = ctx.best_cars.lock().unwrap(); - // Should appear at RQ 85, 90, 95, ..., 115 - for rq in 85..=115 { + // Should appear at RQ 85, 90, 95, ..., MAX_RQ + for rq in 85..=MAX_RQ { let key = ("laguna".to_string(), rq, "Dry".to_string()); assert!( best_cars.contains_key(&key), @@ -1186,9 +918,6 @@ mod tests { // - Valid times should be processed normally // - This logic is tested indirectly through the parsing functions - // Note: Direct testing of global state is complex due to mutex locking - // In a production environment, we'd use mocks or dependency injection - let valid_time = 112.45; // Valid time let invalid_time = 1.0; // Invalid time (too low) @@ -1236,174 +965,62 @@ mod tests { assert!(!name.is_empty(), "Condition name should not be empty"); } } -} -/// Translates internal track identifiers to human-readable track names -/// -/// Converts compact internal track codes (like "laguna", "monaco", "testBowl") -/// to their full, descriptive names that appear in the Top Drives game interface. -/// -/// # Arguments -/// -/// * `track_code` - The internal track identifier from the timing data -/// -/// # Returns -/// -/// Returns the full track name as displayed in the game: -/// - "laguna" → "Laguna Seca" -/// - "monaco" → "Monaco Streets" -/// - "testBowl" → "Test Bowl" -/// - And hundreds more track mappings -/// -/// # Coverage -/// -/// Contains mappings for all known tracks in the Top Drives game, including -/// special tracks like drag strips, rally stages, and circuit races. -/// -/// # Note -/// -/// If an unknown track code is provided, the original code is returned -/// unchanged, allowing the system to gracefully handle new tracks. -fn translate_track_name(track_code: &str) -> &str { - match track_code { - "drag100b" => "0-100-0mph", - "drag100" => "0-100mph", - "drag120" => "0-120mph", - "drag124" => "0-124mph", - "drag150b" => "0-150-0mph", - "drag150" => "0-150mph", - "drag170" => "0-170mph", - "drag200" => "0-200mph", - "drag60b" => "0-60-0mph", - "drag60" => "0-60mph", - "drag62" => "0-62mph", - "mile1" => "1 Mile Drag", - "mile1r" => "1 Mile Drag R", - "mile2" => "1/2 Mile Drag", - "mile4" => "1/4 Mile Drag", - "mile4r" => "1/4 Mile Drag R", - "drag100150" => "100-150mph", - "drag30130" => "30-130mph", - "drag50150" => "50-150mph", - "drag75125" => "75-125mph", - "airplaneHangars" => "Airplane Hangars", - "airplaneSlalom" => "Airplane Slalom", - "smallRoad" => "Back Road", - "butte" => "Butte", - "canyonButte" => "Canyon Butte", - "lookout" => "Lookout", - "canyonLookout" => "Canyon Lookout", - "carPark" => "Car Park", - "townSlalom" => "City Slalom", - "townSprint" => "City Sprint", - "csMed" => "City Streets Medium", - "csSmall" => "City Streets Small", - "townTour" => "City Tour", - "cross" => "Cross-Country", - "dealsGap" => "Deals Gap", - "dealsGap1" => "Deals Gap Relay 1", - "dealsGap2" => "Deals Gap Relay 2", - "dealsGap3" => "Deals Gap Relay 3", - "dealsGap4" => "Deals Gap Relay 4", - "dealsGap5" => "Deals Gap Relay 5", - "dealsGapBack" => "Deals Gap Backroads", - "desertBig" => "Desert Rally Big", - "desertHill" => "Desert Rally Hill", - "desertRallyDirt" => "Desert Rally Dirt", - "desertSmall" => "Desert Rally Small", - "dockAltCirc" => "Dockyard Alternative Circuit", - "dockCirc" => "Dockyard Circuit", - "dockCity" => "Dockyard City Streets", - "dockGforceDrag" => "Dockyard G-Force Drag", - "dockKart" => "Dockyard Karting", - "dockUdrag" => "Dockyard U-Drag", - "dtRoad" => "Dirt Road", - "canyonDtRoad" => "Canyon Dirt Road", - "fast" => "Fast Circuit", - "fastr" => "Fast Circuit R", - "fieldSlalom" => "Field Slalom", - "figureEight" => "Figure Of Eight", - "forestRiver" => "Forest River Crossing", - "forest" => "Forest Road", - "forestSlalom" => "Forest Slalom", - "frozenLake" => "Frozen Lake", - "frozenRivSla" => "Frozen River Slalom", - "gForce" => "G-Force Test", - "gForcer" => "G-Force Test R", - "hairpin" => "Hairpin Road", - "hClimb" => "Hill Climb", - "hClimbr" => "Hill Climb R", - "indoorKart" => "Indoor Karting", - "kart" => "Karting Circuit", - "draglshape" => "L-Shape Drag", - "laguna" => "Laguna Seca", - "lumberForest" => "Lumber Mill Forest Rally", - "lumberRiver" => "Lumber Mill River Rally", - "lumberTwisty" => "Lumber Mill Twisty Circuit", - "miCircuit" => "Miami Circuit", - "miStreets1" => "Miami Streets 1", - "miStreets2" => "Miami Streets 2", - "miUdrag" => "Miami U-Drag", - "miCause" => "Miami Causeway", - "miCauseDrag" => "Miami Causeway Drag", - "miBridge" => "Miami Bridge", - "miFigure" => "Miami Figure Of Eight", - "miSlalom" => "Miami Slalom", - "miGforce" => "Miami G-Force", - "miBeachDrag" => "Miami Beach Drag", - "mnGforce" => "Monaco G-Force Test", - "mnHairpin" => "Monaco Hairpin", - "mnCityNarrow" => "Monaco Narrow Streets", - "mnCity" => "Monaco Streets", - "mnCityLong" => "Monaco Streets Long", - "moto" => "Motocross Track", - "mtHairpin" => "Mountain Hairpin", - "mtHill" => "Mountain Hill Climb", - "mtIncline" => "Mountain Incline Road", - "mtSlalom" => "Mountain Slalom", - "mtTour" => "Mountain Tour", - "mtTwisty" => "Mountain Twisty Road", - "nwCircuit" => "New York Circuit", - "nwDrag" => "New York Drag", - "nwGforce" => "New York G-Force", - "nwLoop" => "New York Times Square Loop", - "nwCathedral" => "New York Cathedral", - "nwTour" => "New York Central Park Tour", - "nwSlalom" => "New York Train Slalom", - "northloop" => "North Loop", - "northloop1" => "North Loop Relay 1", - "northloop2" => "North Loop Relay 2", - "northloop3" => "North Loop Relay 3", - "northloop4" => "North Loop Relay 4", - "northloop5" => "North Loop Relay 5", - "oceanSlalom" => "Ocean Beach Slalom", - "oceanCity" => "Ocean City Streets", - "oceanHighway" => "Ocean Highway", - "oceanLongDrag" => "Ocean Long Drag", - "oceanParking" => "Ocean Parking Lot", - "oceanShortDrag" => "Ocean Short Drag", - "rallyMed" => "Rallycross Medium Circuit", - "rallySmall" => "Rallycross Small Circuit", - "riverSprint" => "River Sprint", - "runwayDrag" => "Runway Drag", - "runwayDragSlalom" => "Runway Drag & Slalom", - "runwayLoop" => "Runway Loop", - "serviceRoads" => "Service Roads", - "slalom" => "Slalom Test", - "slalomr" => "Slalom Test R", - "speedbump12km" => "Speedbump 1 km", - "speedbump1km" => "Speedbump 1/2 km", - "testBowl" => "Test Bowl", - "testBowlr" => "Test Bowl R", - "tokyoBridge" => "Tokyo Bridge", - "tokyoDrag" => "Tokyo Drag", - "tokyoGforce" => "Tokyo G-Force Test", - "tokyoLoop" => "Tokyo Loop", - "tokyoOffRamp" => "Tokyo Off-Ramp", - "tokyoOverpass" => "Tokyo Overpass", - "tCircuit" => "Twisty Circuit", - "tCircuitr" => "Twisty Circuit R", - "tRoad" => "Twisty Road", - "waterDrag" => "Waterfront Drag", - _ => track_code, + + #[test] + fn test_time_tolerance_constant() { + // Verify the time tolerance is 5ms as required + // We test this indirectly by verifying tie behavior + let ctx = AnalysisContext::new(); + + let record1 = BestCarRecord { + car_rid: "Car_A".to_string(), + brand: "Brand".to_string(), + only_name: "Model".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.0, + owned: false, + guid: "guid_a".to_string(), + }; + + let record2 = BestCarRecord { + car_rid: "Car_B".to_string(), + brand: "Brand".to_string(), + only_name: "Model".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.003, // Within 0.005 tolerance + owned: false, + guid: "guid_b".to_string(), + }; + + let record3 = BestCarRecord { + car_rid: "Car_C".to_string(), + brand: "Brand".to_string(), + only_name: "Model".to_string(), + year: 2020, + rq: 85, + config: "111".to_string(), + time: 100.006, // Outside 0.005 tolerance + owned: false, + guid: "guid_c".to_string(), + }; + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3); + + let best_cars = ctx.best_cars.lock().unwrap(); + let key = ("laguna".to_string(), 85, "Dry".to_string()); + let records = best_cars.get(&key).unwrap(); + + // record1 and record2 should both be present (within tolerance) + // record3 should not be added (outside tolerance, and worse time) + assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance"); + assert!(records.iter().any(|r| r.car_rid == "Car_A")); + assert!(records.iter().any(|r| r.car_rid == "Car_B")); + assert!(!records.iter().any(|r| r.car_rid == "Car_C")); } } diff --git a/track_names.json b/track_names.json new file mode 100644 index 0000000..58fc4b6 --- /dev/null +++ b/track_names.json @@ -0,0 +1,141 @@ +{ + "airplaneHangars": "Airplane Hangars", + "airplaneSlalom": "Airplane Slalom", + "butte": "Butte", + "canyonButte": "Canyon Butte", + "canyonDtRoad": "Canyon Dirt Road", + "canyonLookout": "Canyon Lookout", + "carPark": "Car Park", + "cross": "Cross-Country", + "csMed": "City Streets Medium", + "csSmall": "City Streets Small", + "dealsGap": "Deals Gap", + "dealsGap1": "Deals Gap Relay 1", + "dealsGap2": "Deals Gap Relay 2", + "dealsGap3": "Deals Gap Relay 3", + "dealsGap4": "Deals Gap Relay 4", + "dealsGap5": "Deals Gap Relay 5", + "dealsGapBack": "Deals Gap Backroads", + "desertBig": "Desert Rally Big", + "desertHill": "Desert Rally Hill", + "desertRallyDirt": "Desert Rally Dirt", + "desertSmall": "Desert Rally Small", + "dockAltCirc": "Dockyard Alternative Circuit", + "dockCirc": "Dockyard Circuit", + "dockCity": "Dockyard City Streets", + "dockGforceDrag": "Dockyard G-Force Drag", + "dockKart": "Dockyard Karting", + "dockUdrag": "Dockyard U-Drag", + "drag100": "0-100mph", + "drag100150": "100-150mph", + "drag100b": "0-100-0mph", + "drag120": "0-120mph", + "drag124": "0-124mph", + "drag150": "0-150mph", + "drag150b": "0-150-0mph", + "drag170": "0-170mph", + "drag200": "0-200mph", + "drag30130": "30-130mph", + "drag50150": "50-150mph", + "drag60": "0-60mph", + "drag60b": "0-60-0mph", + "drag62": "0-62mph", + "drag75125": "75-125mph", + "draglshape": "L-Shape Drag", + "dtRoad": "Dirt Road", + "fast": "Fast Circuit", + "fastr": "Fast Circuit R", + "fieldSlalom": "Field Slalom", + "figureEight": "Figure Of Eight", + "forest": "Forest Road", + "forestRiver": "Forest River Crossing", + "forestSlalom": "Forest Slalom", + "frozenLake": "Frozen Lake", + "frozenRivSla": "Frozen River Slalom", + "gForce": "G-Force Test", + "gForcer": "G-Force Test R", + "hairpin": "Hairpin Road", + "hClimb": "Hill Climb", + "hClimbr": "Hill Climb R", + "indoorKart": "Indoor Karting", + "kart": "Karting Circuit", + "laguna": "Laguna Seca", + "lookout": "Lookout", + "lumberForest": "Lumber Mill Forest Rally", + "lumberRiver": "Lumber Mill River Rally", + "lumberTwisty": "Lumber Mill Twisty Circuit", + "miBeachDrag": "Miami Beach Drag", + "miBridge": "Miami Bridge", + "miCause": "Miami Causeway", + "miCauseDrag": "Miami Causeway Drag", + "miCircuit": "Miami Circuit", + "miFigure": "Miami Figure Of Eight", + "miGforce": "Miami G-Force", + "miSlalom": "Miami Slalom", + "miStreets1": "Miami Streets 1", + "miStreets2": "Miami Streets 2", + "miUdrag": "Miami U-Drag", + "mile1": "1 Mile Drag", + "mile1r": "1 Mile Drag R", + "mile2": "1/2 Mile Drag", + "mile4": "1/4 Mile Drag", + "mile4r": "1/4 Mile Drag R", + "mnCity": "Monaco Streets", + "mnCityLong": "Monaco Streets Long", + "mnCityNarrow": "Monaco Narrow Streets", + "mnGforce": "Monaco G-Force Test", + "mnHairpin": "Monaco Hairpin", + "moto": "Motocross Track", + "mtHairpin": "Mountain Hairpin", + "mtHill": "Mountain Hill Climb", + "mtIncline": "Mountain Incline Road", + "mtSlalom": "Mountain Slalom", + "mtTour": "Mountain Tour", + "mtTwisty": "Mountain Twisty Road", + "northloop": "North Loop", + "northloop1": "North Loop Relay 1", + "northloop2": "North Loop Relay 2", + "northloop3": "North Loop Relay 3", + "northloop4": "North Loop Relay 4", + "northloop5": "North Loop Relay 5", + "nwCathedral": "New York Cathedral", + "nwCircuit": "New York Circuit", + "nwDrag": "New York Drag", + "nwGforce": "New York G-Force", + "nwLoop": "New York Times Square Loop", + "nwSlalom": "New York Train Slalom", + "nwTour": "New York Central Park Tour", + "oceanCity": "Ocean City Streets", + "oceanHighway": "Ocean Highway", + "oceanLongDrag": "Ocean Long Drag", + "oceanParking": "Ocean Parking Lot", + "oceanShortDrag": "Ocean Short Drag", + "oceanSlalom": "Ocean Beach Slalom", + "rallyMed": "Rallycross Medium Circuit", + "rallySmall": "Rallycross Small Circuit", + "riverSprint": "River Sprint", + "runwayDrag": "Runway Drag", + "runwayDragSlalom": "Runway Drag & Slalom", + "runwayLoop": "Runway Loop", + "serviceRoads": "Service Roads", + "slalom": "Slalom Test", + "slalomr": "Slalom Test R", + "smallRoad": "Back Road", + "speedbump12km": "Speedbump 1 km", + "speedbump1km": "Speedbump 1/2 km", + "tCircuit": "Twisty Circuit", + "tCircuitr": "Twisty Circuit R", + "testBowl": "Test Bowl", + "townSlalom": "City Slalom", + "townSprint": "City Sprint", + "townTour": "City Tour", + "testBowlr": "Test Bowl R", + "tokyoBridge": "Tokyo Bridge", + "tokyoDrag": "Tokyo Drag", + "tokyoGforce": "Tokyo G-Force Test", + "tokyoLoop": "Tokyo Loop", + "tokyoOffRamp": "Tokyo Off-Ramp", + "tokyoOverpass": "Tokyo Overpass", + "tRoad": "Twisty Road", + "waterDrag": "Waterfront Drag" +} diff --git a/tracks.txt b/tracks.txt deleted file mode 100644 index 9f51cbd..0000000 --- a/tracks.txt +++ /dev/null @@ -1,275 +0,0 @@ -4 0-100-0 Dry -5 0-100-0 Wet -6 0-100-0 Dirt -7 0-100 Dry -8 0-100 Wet -9 0-100 Dirt -10 0-100 Gravel -11 0-120 Dry -12 0-120 Wet -13 0-150-0 Dry -14 0-150 Dry -15 0-150 Dirt -16 0-150 Wet -17 0-170 Dry -18 0-200 Dry -19 1 Mile Drag Dry -20 1 Mile Drag Wet -21 1 Mile Drag Dirt -22 1 Mile Drag Gravel -23 Mile Drag Sand -24 Mile Drag Snow -25 1 Mile Drag (R) Dry -26 1/2 Mile Drag Dry -27 1/2 Mile Drag Wet -28 1/2 Mile Drag Dirt, Wet -29 1/2 Mile Drag Gravel -30 1/2 Mile Drag Sand -31 1/2 Mile Drag Snow -32 1/4 Mile Drag Dry -33 1/4 Mile Drag Wet -34 1/4 Mile Drag Dirt, Wet -35 1/4 Mile Drag Gravel -36 1/4 Mile Drag Sand -37 1/4 Mile Drag Snow -38 1/4 Mile Drag Ice -39 1/4 Mile Drag (R) Dry -40 100-150 (R) Dry -41 30-130 (R) Dry -42 50-150 (R) Dry -43 75-125 (R) Dry -44 75-125 (R) Wet -45 Airplane Hangars Dry -46 Airplane Hangars Wet -47 Airplane Slalom Dirt -48 Back Road Asphalt, Dirt, Wet -49 Butte Asphalt, Dirt -50 Canyon Butte Asphalt, Dirt, Wet -51 Canyon Butte Sand, Dirt -52 Canyon Dirt Road Asphalt, Dirt, Wet -53 Canyon Dirt Road Sand, Dirt -54 Canyon Lookout Dirt, Wet -55 Canyon Lookout Sand -56 Canyon Tour Wet -57 Canyon Tour Dirt -58 Canyon Tour Sand -59 Canyon Tour Snow -60 Car Park Dry -61 Car Park Wet -62 City Slalom Dry -63 City Slalom Wet -64 City Slalom Snow -65 City Sprint Dry -66 City Sprint Wet -67 City Streets Medium Dry -68 City Streets Medium Wet -69 City Streets Medium Sand -70 City Streets Medium Snow -71 City Streets Small Dry -72 City Streets Small Wet -73 City Streets Small Sand -74 City Streets Small Snow -75 City Tour Dry -76 City Tour Wet -77 City Tour Snow -78 Cross Country Asphalt, Grass, Dirt -79 Desert Rally Big Asphalt, Dirt -80 Desert Rally Dirt Asphalt, Dirt -81 Desert Rally Hill Asphalt, Dirt -82 Desert Rally Small Asphalt, Dirt -83 Dirt Road Asphalt, Dirt -84 Fast Circuit Dry -85 Fast Circuit Wet -86 Fast Circuit (R) Dry -87 Field Slalom Asphalt, Grass, Dirt -88 Field Slalom Grass -89 Figure Of Eight Wet -90 Figure Of Eight Asphalt, Sand -91 Figure Of Eight Asphalt, Gravel -92 Figure Of Eight Asphalt, Snow -93 Forest River Crossing Asphalt, Dirt, Wet -94 Forest River Crossing Asphalt, Dirt -95 Forest River Crossing Asphalt, Gravel -96 Forest Road Dry -97 Forest Road Wet -98 Forest Road Snow -99 Forest Road Ice -100 Forest Slalom Dirt -101 Forest Slalom Dirt, Wet -102 Forest Slalom Gravel -103 Forest Slalom Snow -104 Forest Slalom Ice -105 Forest Slalom Grass -106 Forest Slalom Grass, Wet -107 Frozen Lake Asphalt, Snow -108 Frozen River Slalom Ice -109 G-Force Test Dry -110 G-Force Test Wet -111 G-Force Test Dirt -112 G-Force Test Dirt, Wet -113 G-Force Test Gravel -114 G-Force Test Sand -115 G-Force Test Snow -116 G-Force Test Ice -117 G-Force Test (R) Dry -118 Hairpin Road Dry -119 Hairpin Road Wet -120 Hairpin Road Dirt -121 Hairpin Road Gravel -122 Hairpin Road Sand -123 Hairpin Road Snow -124 Hill Climb Dry -125 Hill Climb Wet -126 Hill Climb Dirt -127 Hill Climb Dirt, Wet -128 Hill Climb Gravel -129 Hill Climb Sand -130 Hill Climb Snow -131 Hill Climb (R) Dirt -132 Indoor Karting Dry -133 Karting Circuit Dry -134 Karting Circuit Wet -135 L-Shape Drag Dry -136 L-Shape Drag Wet -137 Laguna Seca Dry -138 Laguna Seca Wet -139 Lookout Dirt -140 Lumber Mill Twisty Circuit Asphalt, Dirt -141 Monaco G-Force Test Dry -142 Monaco G-Force Test Wet -143 Monaco Hairpin Dry -144 Monaco Hairpin Wet -145 Monaco Hairpin Asphalt, Dirt, Wet -146 Monaco Hairpin Asphalt, Dirt -147 Monaco Narrow Streets Dry -148 Monaco Narrow Streets Wet -149 Monaco Streets Dry -150 Monaco Streets Wet -151 Monaco Streets Long Dry -152 Monaco Streets Long Wet -153 Motocross Track Dirt -154 Motocross Track Wet -155 Mountain Hairpin Dry -156 Mountain Hairpin Wet -157 Mountain Hairpin Dirt -158 Mountain Hairpin Dirt, Wet -159 Mountain Hairpin Snow -160 Mountain Hill Climb Dry -161 Mountain Hill Climb Wet -162 Mountain Hill Climb Dirt -163 Mountain Hill Climb Dirt, Wet -164 Mountain Hill Climb Snow -165 Mountain Incline Road Dry -166 Mountain Incline Road Wet -167 Mountain Incline Road Dirt -168 Mountain Incline Road Dirt, Wet -169 Mountain Incline Road Snow -170 Mountain Slalom Dry -171 Mountain Slalom Wet -172 Mountain Slalom Dirt -173 Mountain Slalom Dirt, Wet -174 Mountain Slalom Snow -175 Mountain Tour Dry -176 Mountain Tour Wet -177 Mountain Tour Dirt -178 Mountain Tour Dirt, Wet -179 Mountain Tour Snow -180 Mountain Twisty Road Dry -181 Mountain Twisty Road Wet -182 Mountain Twisty Road Dirt -183 Mountain Twisty Road Dirt, Wet -184 Mountain Twisty Road Snow -185 North Loop Dry -186 North Loop Wet -187 North Loop Relay 1 Dry -188 North Loop Relay 1 Snow -189 North Loop Relay 2 Dry -190 North Loop Relay 2 Snow -191 North Loop Relay 3 Dry -192 North Loop Relay 3 Wet -193 North Loop Relay 4 Dry -194 North Loop Relay 4 Wet -195 North Loop Relay 4 Snow -196 North Loop Relay 5 Dry -197 North Loop Relay 5 Wet -198 North Loop Relay 5 Snow -199 Ocean Beach Slalom Asphalt, Sand -200 Ocean Beach Slalom Asphalt, Sand, Wet -201 Ocean City Streets Dry -202 Ocean City Streets Wet -203 Ocean Highway Dry -204 Ocean Highway Wet -205 Ocean Long Drag Dry -206 Ocean Long Drag Wet -207 Ocean Parking Lot Dry -208 Ocean Parking Lot Wet -209 Ocean Short Drag Dry -210 Ocean Short Drag Wet -211 Rallycross Medium Asphalt, Dirt, Wet -212 Rallycross Medium Asphalt, Dirt -213 Rallycross Small Asphalt, Dirt, Wet -214 Rallycross Small Asphalt, Dirt -215 River Sprint Ice, Snow -216 Runway Drag Dry -217 Runway Drag Wet -218 Runway Drag & Slalom Dry -219 Runway Drag & Slalom Wet -220 Runway Loop Dry -221 Runway Loop Wet -222 Service Roads Dry -223 Service Roads Wet -224 Service Roads Dirt -225 Slalom Test Dry -226 Slalom Test Wet -227 Slalom Test Dirt -228 Slalom Test Dirt, Wet -229 Slalom Test Gravel -230 Slalom Test Sand -231 Slalom Test Snow -232 Slalom Test Ice -233 Slalom Test (R) Dry -234 Speedbump 1/2km Dry -235 Speedbump 1/2km Snow -236 Speedbump 1km Dry -237 Test Bowl Dry -238 Test Bowl Wet -239 Test Bowl Dirt -240 Test Bowl Snow -241 Test Bowl (R) Dry -242 Test Bowl (R) Wet -243 Test Bowl (R) Gravel -244 Test Bowl (R) Snow -245 Tokyo Bridge Dry -246 Tokyo Bridge Wet -247 Tokyo Drag Dry -248 Tokyo Drag Wet -249 Tokyo G-Force Test Dry -250 Tokyo G-Force Test Wet -251 Tokyo Loop (R) Dry -252 Tokyo Loop (R) Wet -253 Tokyo Off Ramp (R) Dry -254 Tokyo Off Ramp (R) Wet -255 Tokyo Overpass (R) Dry -256 Tokyo Overpass (R) Wet -257 Twisty Circuit Dry -258 Twisty Circuit Wet -259 Twisty Circuit Dirt -260 Twisty Circuit Sand -261 Twisty Circuit Snow -262 Twisty Circuit (R) Dry -263 Twisty Circuit (R) Wet -264 Twisty Road Dry -265 Twisty Road Wet -266 Twisty Road Dirt -267 Twisty Road Dirt, Wet -268 Twisty Road Gravel -269 Twisty Road Sand -270 Twisty Road Snow -271 Twisty Road Ice -272 Waterfront Drag Dry -273 Waterfront Drag Wet -274 0-60 Gravel -275 0-60-0 Wet -276 0-60-0 Sand -277 Lumber Mill Twisty Circuit Asphalt, Dirt, Wet -278 Lumber Mill Twisty Circuit Snow, Dirt, Wet \ No newline at end of file