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
This commit is contained in:
Jan-Peter von Hunnius 2026-05-06 20:20:15 +02:00
parent 6a68469239
commit 89fa69e3c8
14 changed files with 874 additions and 2037 deletions

View file

@ -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 - User guide with command examples and troubleshooting
- Data structures documentation for JSON formats - Data structures documentation for JSON formats
- Development setup guide with dependency explanations - 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 - Enhanced error handling throughout the application
- Better progress reporting during data fetching - Better progress reporting during data fetching
- More descriptive command-line help text - More descriptive command-line help text
- Improved CSV output formatting - 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 ## [0.2.0] - 2024-12-XX
### Added ### Added
- **Club Module**: Alternative data source using web scraping from topdrives.club
- **Track-Specific Analysis**: New `track` command for targeted recommendations - **Track-Specific Analysis**: New `track` command for targeted recommendations
- **Enhanced Output**: CSV file generation for analysis results - **Enhanced Output**: CSV file generation for analysis results
- **Improved Concurrency**: Better handling of multiple simultaneous downloads - **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 ### Data Processing
- **[0.1.0]** Basic JSON parsing and analysis - **[0.1.0]** Basic JSON parsing and analysis
- **[0.2.0]** Enhanced error handling and data validation - **[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 ### Performance
- **[0.1.0]** Single-threaded processing - **[0.1.0]** Single-threaded processing
- **[0.2.0]** Concurrent downloads with rate limiting - **[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 ### User Interface
- **[0.1.0]** Basic command-line interface - **[0.1.0]** Basic command-line interface
- **[0.2.0]** Enhanced output formatting and CSV export - **[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 ### Data Sources
- **[0.1.0]** JSON-based records from community repository - **[0.1.0]** JSON-based records from community repository
- **[0.2.0]** Added HTML scraping from topdrives.club - **[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 ## 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 ### Upgrading from 0.1.0 to 0.2.0
**No breaking changes** - the upgrade is fully backwards compatible. **No breaking changes** - the upgrade is fully backwards compatible.
**New Features Available:** **New Features Available:**
1. **Club Module**: Alternative data source with `cargo run --bin club` 1. **Track Command**: Specific track analysis with `cargo run --bin records -- track`
2. **Track Command**: Specific track analysis with `cargo run --bin records -- track` 2. **CSV Output**: Automatic generation of analysis files
3. **CSV Output**: Automatic generation of analysis files
**Performance Improvements:** **Performance Improvements:**
- Faster data fetching with concurrent downloads - 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 ## Deprecation Notices
### Future Removals ### Removed in Unreleased
- **None currently planned** - **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 ### Deprecated Features
- **None currently deprecated** - **None currently deprecated**
@ -122,7 +149,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Data Processing Speed ### Data Processing Speed
- **Initial Load**: ~2-3 minutes (downloads 1000+ files) - **Initial Load**: ~2-3 minutes (downloads 1000+ files)
- **Subsequent Runs**: <30 seconds (uses cached data) - **Subsequent Runs**: <30 seconds (uses cached data)
- **Concurrent Operations**: 20 simultaneous downloads - **Concurrent Operations**: 20 simultaneous downloads (semaphore bounded)
### Memory Usage ### Memory Usage
- **Base Application**: ~50MB - **Base Application**: ~50MB
@ -132,7 +159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Network Efficiency ### Network Efficiency
- **Compression**: Efficient HTTP compression - **Compression**: Efficient HTTP compression
- **Connection Reuse**: Persistent HTTP connections - **Connection Reuse**: Persistent HTTP connections
- **Rate Limiting**: Respects server constraints - **Rate Limiting**: Semaphore-bounded concurrency (limit 20)
## Known Issues and Limitations ## Known Issues and Limitations
@ -150,15 +177,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Future Roadmap ## Future Roadmap
### Planned Features (Unreleased) ### Planned Features (Unreleased)
- **Web Interface**: Browser-based GUI for non-technical users
- **Historical Analysis**: Track performance changes over time - **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 - **Advanced Analytics**: Statistical analysis and trend detection
### Potential Enhancements ### Potential Enhancements
- **Real-time Updates**: Live data synchronization - **Real-time Updates**: Live data synchronization
- **Social Features**: Share collections and recommendations
- **Customization**: User-defined analysis parameters - **Customization**: User-defined analysis parameters
- **Export Options**: Multiple file formats (PDF, Excel) - **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 ### Data Sources
- **Top Drives Community**: Primary timing data source - **Top Drives Community**: Primary timing data source
- **topdrives.club**: Alternative data source
- **GitHub Contributors**: Track and car database maintenance - **GitHub Contributors**: Track and car database maintenance
--- ---
**Changelog Format**: Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format **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 **Versioning**: Follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) principles
**Last Updated**: 2024-12-XX **Last Updated**: 2026-05-06

309
Cargo.lock generated
View file

@ -32,12 +32,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@ -86,40 +80,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -131,27 +91,6 @@ dependencies = [
"syn", "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]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -225,16 +164,6 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@ -323,24 +252,6 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -405,18 +316,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 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]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@ -717,37 +616,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@ -788,12 +656,6 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -872,58 +734,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -945,12 +755,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -985,21 +789,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -1136,21 +925,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@ -1174,25 +948,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -1253,15 +1008,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -1278,12 +1024,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -1312,31 +1052,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -1408,17 +1123,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@ -1496,7 +1200,6 @@ version = "0.2.0"
dependencies = [ dependencies = [
"futures", "futures",
"reqwest", "reqwest",
"scraper",
"serde_json", "serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
@ -1578,12 +1281,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -1608,12 +1305,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"

View file

@ -3,16 +3,15 @@ name = "top_drives"
version = "0.2.0" version = "0.2.0"
edition = "2021" edition = "2021"
[[bin]] [lib]
name = "club" name = "top_drives"
path = "src/club.rs" path = "src/lib.rs"
[[bin]] [[bin]]
name = "records" name = "records"
path = "src/records.rs" path = "src/records.rs"
[dependencies] [dependencies]
scraper = "0.23.1"
reqwest = "0.12.15" reqwest = "0.12.15"
tokio = { version = "1.44.1", features = ["full"] } tokio = { version = "1.44.1", features = ["full"] }
serde_json = "1.0.140" serde_json = "1.0.140"

View file

@ -17,6 +17,7 @@ A comprehensive Rust-based tool for analyzing car performance data in the Top Dr
- [Quick Start](#quick-start) - [Quick Start](#quick-start)
- [Usage Guide](#usage-guide) - [Usage Guide](#usage-guide)
- [Commands Reference](#commands-reference) - [Commands Reference](#commands-reference)
- [Daily Workflow](#daily-workflow)
- [Data Sources](#data-sources) - [Data Sources](#data-sources)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Development](#development) - [Development](#development)
@ -148,6 +149,36 @@ The analyzer supports multiple track conditions:
| `details` | Show detailed timing data for all tracks | None | | `details` | Show detailed timing data for all tracks | None |
| `track` | Show best car for specific track | `<track_name> <condition> <rq>` | | `track` | Show best car for specific track | `<track_name> <condition> <rq>` |
## 📅 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 ## 📊 Data Sources
### Car Data ### Car Data
@ -160,6 +191,11 @@ The analyzer supports multiple track conditions:
- **Coverage**: All tracks and conditions in Top Drives - **Coverage**: All tracks and conditions in Top Drives
- **Format**: Structured track definitions with conditions - **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 ### User Collection
- **File**: `my_cars.json` - **File**: `my_cars.json`
- **Format**: Top Drives game export format - **Format**: Top Drives game export format
@ -172,10 +208,11 @@ The analyzer supports multiple track conditions:
top_drives/ top_drives/
├── src/ ├── src/
│ ├── records.rs # Main analyzer binary │ ├── records.rs # Main analyzer binary
│ └── club.rs # Alternative data source │ └── lib.rs # Shared library (track name translations)
├── times/ # Car performance data (1000+ files) ├── times/ # Car performance data (1000+ files)
├── cars_repo.json # Car database ├── cars_repo.json # Car database
├── tracks_repo.json # Track definitions ├── tracks_repo.json # Track definitions
├── track_names.json # Track code → name mappings
├── my_cars.json # Your car collection ├── my_cars.json # Your car collection
└── Cargo.toml # Rust project configuration └── Cargo.toml # Rust project configuration
``` ```
@ -184,15 +221,20 @@ top_drives/
- **reqwest**: HTTP client for data fetching - **reqwest**: HTTP client for data fetching
- **serde_json**: JSON parsing and serialization - **serde_json**: JSON parsing and serialization
- **tokio**: Asynchronous runtime - **tokio**: Asynchronous runtime
- **scraper**: HTML parsing for alternative data source - **futures**: Async utility combinators
- **once_cell**: Lazy static initialization
## 🔧 Development ## 🔧 Development
### Project Structure ### 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 1. **records**: Primary analysis tool using JSON data (`src/records.rs`)
2. **club**: Alternative tool scraping topdrives.club 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 ### Key Data Structures
@ -232,9 +274,8 @@ cargo build
# Release build # Release build
cargo build --release cargo build --release
# Run specific binary # Run the analyzer
cargo run --bin records cargo run --bin records
cargo run --bin club
``` ```
## 🤝 Contributing ## 🤝 Contributing
@ -252,6 +293,8 @@ Track definitions are stored in `tracks_repo.json`. Each track includes:
- Supported conditions - Supported conditions
- Track type classification - Track type classification
Track name mappings are stored in `track_names.json` and loaded at compile time.
### Adding Car Data ### Adding Car Data
New car performance data should be submitted to the upstream repository following their contribution guidelines. New car performance data should be submitted to the upstream repository following their contribution guidelines.

View file

@ -23,7 +23,7 @@ async fn main()
**Purpose**: Command-line interface and operation dispatcher **Purpose**: Command-line interface and operation dispatcher
**Arguments**: Command line arguments **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**: **Commands**:
- `fetch`: Download all data files - `fetch`: Download all data files
@ -32,6 +32,7 @@ async fn main()
- `trash`: Show removable cars - `trash`: Show removable cars
- `details`: Export all timing data - `details`: Export all timing data
- `track`: Track-specific analysis - `track`: Track-specific analysis
- `full_script`: Run fetch + all analysis commands
**Example**: **Example**:
```bash ```bash
@ -148,7 +149,7 @@ async fn parse_all_times()
**Purpose**: Coordinates parallel processing of car timing files **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 **Synchronization**: Waits for all tasks to complete
##### `update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord)` ##### `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 **Purpose**: Implements core best-car selection algorithm
**RQ Range Analysis**: **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 - Compares against existing best times
- Updates records for improved performance - Updates records for improved performance
**Logic**: **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 2. If new time is better → replace record
3. If new time ties → add to list (if not duplicate) 3. If new time ties → add to list (if not duplicate)
4. If new time is worse → ignore 4. If new time is worse → ignore
@ -205,7 +206,7 @@ async fn fetch_times()
**Purpose**: Batch download of car timing data **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 **Progress**: Shows download progress for each file
##### `fetch_car(rid: String)` ##### `fetch_car(rid: String)`
@ -258,6 +259,10 @@ async fn get_file(file_url: &str, local_file_path: &str)
- Error handling with graceful failure - Error handling with graceful failure
- Progress indication through errors - Progress indication through errors
### Shared Library (`src/lib.rs`)
Common utilities shared across the project.
##### `translate_track_name(track_code: &str) -> &str` ##### `translate_track_name(track_code: &str) -> &str`
**Converts track codes to human-readable names** **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 **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 **Fallback**: Returns original code if not found
##### `translate_track_type(track_code: &str) -> &str` ##### `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")) 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<u16, String>`
**Loads track definitions from file**
```rust
fn parse_tracks(file_name: &str) -> HashMap<u16, String>
```
**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<String>, index: usize, min: u16, max: u16) -> u16`
**Validates command line arguments**
```rust
fn parse_argument(args: &Vec<String>, index: usize, min: u16, max: u16) -> u16
```
**Purpose**: Input validation with range checking
**Panics**: On invalid values with descriptive messages
## Data Structures ## Data Structures
### Records Module Data Types ### Records Module Data Types
@ -436,22 +360,15 @@ type TripletKey = (String, u8, String);
// Track ID RQ Level Surface Condition // Track ID RQ Level Surface Condition
``` ```
### Club Module Data Types ### Constants
#### `Car`
**Scraped car data from website**
#### `MAX_RQ`
```rust ```rust
struct Car { const MAX_RQ: u8 = 113;
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
}
``` ```
**Purpose**: Maximum RQ level for range analysis. Replaces former magic number 115 with a named constant.
## Global State ## Global State
### `BEST_CARS` Cache ### `BEST_CARS` Cache
@ -533,7 +450,7 @@ fetch_times().await;
- **Per Car**: ~1-2KB metadata + timing data - **Per Car**: ~1-2KB metadata + timing data
### Concurrency ### Concurrency
- **Downloads**: 20 concurrent connections max - **Downloads**: 20 concurrent connections max (semaphore bounded)
- **Processing**: One task per car file - **Processing**: One task per car file
- **Thread Safety**: Mutex-protected shared state - **Thread Safety**: Mutex-protected shared state
@ -544,41 +461,16 @@ fetch_times().await;
2. Implement analysis function 2. Implement analysis function
3. Update help documentation 3. Update help documentation
### Adding New Data Sources ### Adding New Tracks
1. Implement new module (similar to `club.rs`) 1. Update `tracks_repo.json` with new track definitions
2. Add data source selection logic 2. Add track name mapping to `track_names.json`
3. Update configuration system 3. Track names are compiled in at build time
### Custom Analysis Types ### Custom Analysis Types
1. Create new output formatting functions 1. Create new output formatting functions
2. Add aggregation algorithms 2. Add aggregation algorithms
3. Extend data structures as needed 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 ## Security Considerations
### Network Security ### Network Security
@ -643,12 +535,14 @@ cargo flamegraph --bin records -- best
- **Future**: Semantic versioning maintained - **Future**: Semantic versioning maintained
### Breaking Changes ### Breaking Changes
- None currently planned - Club module (`src/club.rs`) has been removed
- Advance notice provided for API changes - `scraper` dependency removed
- Deprecation warnings for removed features - `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 **API Version**: 0.2.0
**Last Updated**: 2024-12-XX **Last Updated**: 2026-05-06
**Compatibility**: Rust 1.70.0+ **Compatibility**: Rust 1.70.0+

View file

@ -2,7 +2,7 @@
## System Overview ## 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 ## 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 - **Performance Analysis**: Compares timing data across RQ ranges and track conditions
- **Collection Management**: Identifies owned cars and optimization opportunities - **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 #### Data Flow
``` ```
@ -32,28 +35,17 @@ User Collection (my_cars.json)
Output: CSV/Terminal Results 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 #### Key Responsibilities
- **Web Scraping**: Fetches HTML content from topdrives.club - **Track Name Translation**: Converts track codes to human-readable names via `translate_track_name()`
- **HTML Parsing**: Extracts car performance data using CSS selectors - **Track Type Translation**: Converts condition codes to surface descriptions via `translate_track_type()`
- **Real-time Data**: Provides fresh data without pre-downloaded files
#### Data Flow #### 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"`)
topdrives.club Website
↓
HTML Content
↓
CSS Selector Parsing
↓
Car Data Extraction
↓
Output: Terminal Results
```
## Data Structures ## Data Structures
@ -69,7 +61,7 @@ type TripletKey = (String, u8, String);
**Purpose**: Composite key for uniquely identifying track conditions **Purpose**: Composite key for uniquely identifying track conditions
**Usage**: HashMap key for storing best car records **Usage**: HashMap key for storing best car records
#### Car (Records Module) #### Car
```rust ```rust
struct Car { struct Car {
rid: String, // Repository ID 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 ## Global State Management
### BEST_CARS Cache ### BEST_CARS Cache
@ -127,7 +107,7 @@ static BEST_CARS: Lazy<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>
The system analyzes car performance across multiple RQ brackets: The system analyzes car performance across multiple RQ brackets:
1. **Base RQ**: Car's natural rarity level 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 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 **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 ### Concurrent Processing
#### Records Module #### Bounded Concurrency Pattern
- **Task Spawning**: One async task per car file - **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 - **Error Isolation**: Individual car failures don't stop batch processing
#### Synchronization #### Synchronization
@ -157,6 +137,7 @@ Multiple validation layers ensure data quality:
``` ```
├── cars_repo.json # Car database from repository ├── cars_repo.json # Car database from repository
├── tracks_repo.json # Track definitions ├── tracks_repo.json # Track definitions
├── track_names.json # Track code → name mappings (compiled in)
├── my_cars.json # User's car collection (game export) ├── my_cars.json # User's car collection (game export)
└── times/ # Individual car timing files └── times/ # Individual car timing files
├── Ferrari_F40_1987.json ├── Ferrari_F40_1987.json
@ -167,8 +148,13 @@ Multiple validation layers ensure data quality:
### Output Files ### Output Files
``` ```
├── best.csv # Best cars analysis results ├── 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 ## Performance Characteristics
@ -178,12 +164,7 @@ Multiple validation layers ensure data quality:
- **Memory Usage**: Moderate (loads all timing data into memory) - **Memory Usage**: Moderate (loads all timing data into memory)
- **Accuracy**: High (comprehensive dataset) - **Accuracy**: High (comprehensive dataset)
- **Update Frequency**: Manual refresh required - **Update Frequency**: Manual refresh required
- **Concurrency**: Bounded at 20 simultaneous operations via semaphore
### 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
## Error Handling Strategy ## Error Handling Strategy
@ -195,13 +176,12 @@ Multiple validation layers ensure data quality:
### User Feedback ### User Feedback
- **Progress Indication**: Shows fetching/download progress - **Progress Indication**: Shows fetching/download progress
- **Error Messages**: Clear descriptions of what went wrong - **Error Messages**: Clear descriptions of what went wrong
- **Fallback Options**: Multiple data sources available
## Extension Points ## Extension Points
### Adding New Tracks ### Adding New Tracks
1. Update `tracks_repo.json` with new track definitions 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()` 3. Add condition mappings in `translate_track_type()`
### Adding New Analysis Types ### Adding New Analysis Types
@ -209,17 +189,12 @@ Multiple validation layers ensure data quality:
2. Add command parsing in main function 2. Add command parsing in main function
3. Update help text and documentation 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 ## Security Considerations
### Network Access ### Network Access
- **HTTPS Only**: All external requests use HTTPS - **HTTPS Only**: All external requests use HTTPS
- **Certificate Validation**: Accepts invalid certificates for compatibility - **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 ### Local File Access
- **Read Only**: No modification of game files - **Read Only**: No modification of game files
@ -229,7 +204,7 @@ Multiple validation layers ensure data quality:
## Future Enhancements ## Future Enhancements
### Potential Improvements ### 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 2. **Database Backend**: Replace in-memory HashMap with persistent storage
3. **Configuration Files**: External configuration for analysis parameters 3. **Configuration Files**: External configuration for analysis parameters
4. **GUI Interface**: Web-based interface for non-technical users 4. **GUI Interface**: Web-based interface for non-technical users

View file

@ -175,6 +175,48 @@ This document describes the JSON data structures used by the Top Drives Records
- Used for track-specific analysis queries - Used for track-specific analysis queries
- Supports condition filtering - 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 ## Data Processing Pipeline
### 1. Data Acquisition Phase ### 1. Data Acquisition Phase
@ -226,6 +268,11 @@ if let Some(configurations) = json_data["data"].as_object() {
- Process: Split on `"_a"` delimiter - Process: Split on `"_a"` delimiter
- Output: `("laguna", "00")` (track name, condition code) - 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:** **Condition Code Translation:**
- `"00"` → `"Dry"` - `"00"` → `"Dry"`
- `"01"` → `"Wet"` - `"01"` → `"Wet"`
@ -255,7 +302,7 @@ BestCarRecord {
``` ```
**RQ Range Analysis:** **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 - Compare against existing best times
- Update records if better performance found - Update records if better performance found
@ -303,7 +350,7 @@ BestCarRecord {
- User collection: Small, loaded once - User collection: Small, loaded once
**Optimization Strategies**: **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) - Rate-limited downloads (20 concurrent max)
- Streaming JSON parsing where possible - Streaming JSON parsing where possible
@ -315,7 +362,7 @@ BestCarRecord {
3. **HashMap Operations**: Frequent insertions with RQ range checking 3. **HashMap Operations**: Frequent insertions with RQ range checking
**Optimizations Applied**: **Optimizations Applied**:
1. **Concurrency**: One task per car file 1. **Bounded Concurrency**: Semaphore limits concurrent operations to 20
2. **Connection Pooling**: Reuse HTTP connections 2. **Connection Pooling**: Reuse HTTP connections
3. **Early Filtering**: Invalid times rejected quickly 3. **Early Filtering**: Invalid times rejected quickly
@ -361,8 +408,8 @@ BestCarRecord {
**Track Name Issues**: **Track Name Issues**:
- **Symptom**: Unknown track codes in output - **Symptom**: Unknown track codes in output
- **Cause**: Outdated track definitions - **Cause**: Missing mapping in `track_names.json`
- **Solution**: Update `tracks_repo.json` - **Solution**: Add new mapping to `track_names.json`
### Data Validation Tools ### Data Validation Tools

View file

@ -168,6 +168,7 @@ serde_json = "1.0.140"
- Parse `my_cars.json` (user collection) - Parse `my_cars.json` (user collection)
- Parse individual car timing files - Parse individual car timing files
- Parse `tracks_repo.json` (track definitions) - Parse `tracks_repo.json` (track definitions)
- Parse `track_names.json` (track name mappings)
**Performance**: Zero-copy parsing where possible **Performance**: Zero-copy parsing where possible
@ -205,26 +206,9 @@ once_cell = "1.20.2"
- Thread-safe access during concurrent parsing - Thread-safe access during concurrent parsing
- Avoid expensive computation on every access - 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 ### Development Dependencies
#### 7. Build Tools and Quality #### 6. Build Tools and Quality
**Recommended additions for development:** **Recommended additions for development:**
```toml ```toml
@ -289,9 +273,9 @@ name = "top_drives"
version = "0.2.0" version = "0.2.0"
edition = "2021" edition = "2021"
[[bin]] [lib]
name = "club" name = "top_drives"
path = "src/club.rs" path = "src/lib.rs"
[[bin]] [[bin]]
name = "records" name = "records"
@ -300,7 +284,6 @@ path = "src/records.rs"
**Binary Configuration:** **Binary Configuration:**
- **records**: Main analysis tool (JSON-based) - **records**: Main analysis tool (JSON-based)
- **club**: Alternative scraper (web-based)
**Dependency Versions:** **Dependency Versions:**
- All dependencies pinned to specific versions for reproducibility - All dependencies pinned to specific versions for reproducibility
@ -342,7 +325,7 @@ RUST_BACKTRACE=1 cargo run
``` ```
**Concurrency Tuning:** **Concurrency Tuning:**
- Default: 20 concurrent downloads (configurable via Semaphore) - Default: 20 concurrent operations (bounded via Semaphore)
- Thread count: Automatic based on CPU cores - Thread count: Automatic based on CPU cores
- Memory usage: ~100-200MB for full dataset - Memory usage: ~100-200MB for full dataset
@ -471,7 +454,6 @@ cargo build --release
**Binary Location:** **Binary Location:**
``` ```
target/release/records # Main analyzer target/release/records # Main analyzer
target/release/club # Club scraper
``` ```
**Cross-Platform Building:** **Cross-Platform Building:**

View file

@ -160,8 +160,8 @@ Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with tim
``` ```
**Track name format:** **Track name format:**
- Use internal names: `laguna`, `monaco`, `testBowl` - Use display names: `Laguna Seca`, `Monaco`, `Test Bowl`
- Full mappings in `translate_track_name()` function - Full mappings loaded from `track_names.json`
**Condition options:** **Condition options:**
- `Dry` - Standard asphalt - `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 - `Dirt` - Off-road tracks
- `Snow` - Winter conditions - `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 ## Advanced Usage
### RQ Range Analysis ### RQ Range Analysis
@ -241,16 +278,24 @@ The tool generates CSV files for external analysis:
- Import into spreadsheet for sorting/filtering - Import into spreadsheet for sorting/filtering
- Track performance across time - Track performance across time
#### missing.csv
- Cars to acquire
- Prioritize by count and RQ relevance
- Plan acquisition strategy
#### details.csv #### details.csv
- Comprehensive timing database - Comprehensive timing database
- Advanced analysis and research - Advanced analysis and research
- Historical performance tracking - 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 ### Spreadsheet Analysis Tips
**Excel/Google Sheets setup:** **Excel/Google Sheets setup:**
@ -265,7 +310,7 @@ The tool generates CSV files for external analysis:
#### "Please provide a command" #### "Please provide a command"
**Problem:** No arguments provided **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" #### "Could not read my cars file"
**Problem:** `my_cars.json` not found or not readable **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 ## Tips and Best Practices
### Regular Maintenance ### Regular Maintenance
1. **Weekly**: Run `fetch` to get latest data 1. **Daily**: Run `full_script` to get latest data and full analysis
2. **Monthly**: Review `missing` cars for new acquisitions 2. **Weekly**: Review `missing` cars for new acquisitions
3. **Quarterly**: Use `trash` to optimize collection 3. **Monthly**: Use `trash` to optimize collection
### Data-Driven Decisions ### Data-Driven Decisions
1. **Trust the numbers**: Count-based recommendations are data-driven 1. **Trust the numbers**: Count-based recommendations are data-driven

View file

@ -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#"
<div class="carList__item-manufacturer">Ferrari</div>
<div class="carList__item-model">F40</div>
<div class="carList__item-year">1987</div>
<div class="carList__item-level">
<span>S</span>
<span>95</span>
</div>
<div class="carList__item-upgrade">323</div>
"#
.to_string()
}
/// Creates mock HTML with missing elements
fn create_incomplete_car_html() -> String {
r#"
<div class="carList__item-manufacturer">McLaren</div>
<div class="carList__item-model">F1</div>
<div class="carList__item-year">1993</div>
"#
.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<u16, String> {
let mut result: HashMap<u16, String> = HashMap::new();
for line in content.lines() {
if let Some((index, track)) = line.split_once(' ') {
if let Ok(id) = index.parse::<u16>() {
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::<Vec<_>>()[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::<Vec<_>>()[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::<Vec<_>>()[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::<Vec<_>>()[0],
});
car.class = class.to_uppercase();
let rq = match element_iterator.next() {
None => "1900",
Some(rq) => rq.text().collect::<Vec<_>>()[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::<Vec<_>>()[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<u16, String>` 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<u16, String> {
let mut result: HashMap<u16, String> = 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::<u16>().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<String, u16> = 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<String>, index: usize, min: u16, max: u16) -> u16 {
match args[index].parse::<u16>() {
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("")
}
}

317
src/lib.rs Normal file
View file

@ -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<Mutex<...>>` for concurrent access from spawned tasks.
pub best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
}
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<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
}
/// 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<String> = 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<String> = 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<HashMap<String, String>> = OnceLock::new();
let map = TRACK_NAMES.get_or_init(|| {
let json_str = include_str!("../track_names.json");
let raw: HashMap<String, String> =
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())
}

View file

@ -30,71 +30,23 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::env; use std::env;
use std::fs; use std::fs;
use std::fs::File; use std::sync::Arc;
use std::io::BufReader;
use std::sync::{Arc, LazyLock, Mutex};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tokio::task; use tokio::task;
use futures::future::join_all; use futures::future::join_all;
use reqwest::get;
use serde_json::Value; use serde_json::Value;
/// Core car information structure containing basic car metadata use top_drives::{
/// Used throughout the application to represent car entities AnalysisContext, BestCarRecord, Car, TripletKey,
#[derive(Debug, Clone)] compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type,
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,
}
/// Represents the best performing car record for a specific track, RQ, and condition /// Maximum RQ level in the Top Drives database.
/// Contains all necessary information to compare car performance /// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
#[derive(Debug, Clone)] const MAX_RQ: u8 = 113;
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<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Main entry point for the Top Drives Records Analyzer /// Main entry point for the Top Drives Records Analyzer
/// ///
@ -140,37 +92,42 @@ async fn main() {
// Standard best cars analysis // Standard best cars analysis
// Shows cars you own that perform well across tracks // Shows cars you own that perform well across tracks
let my_data = load_my_cars_data(); let my_data = load_my_cars_data();
let _car_list = parse_all_times(&my_data.owned).await; let ctx = AnalysisContext::new();
print_best_cars(false, &my_data.wishlist); // false = include owned cars 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" => {
// Missing cars analysis // Missing cars analysis
// Shows high-performing cars you don't own // Shows high-performing cars you don't own
// Cars not yet on the wishlist are prefixed with "+" // Cars not yet on the wishlist are prefixed with "+"
let my_data = load_my_cars_data(); let my_data = load_my_cars_data();
let _car_list = parse_all_times(&my_data.owned).await; let ctx = AnalysisContext::new();
print_best_cars(true, &my_data.wishlist); // true = filter out owned cars 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" => {
// Wishlist review // Wishlist review
// Shows cars on the wishlist that are no longer among the best // Shows cars on the wishlist that are no longer among the best
let my_data = load_my_cars_data(); let my_data = load_my_cars_data();
let car_list = parse_all_times(&my_data.owned).await; let ctx = AnalysisContext::new();
print_wishlist_stale(&my_data.wishlist, &car_list); let car_list = parse_all_times(&my_data.owned, &ctx).await;
print_wishlist_stale(&ctx, &my_data.wishlist, &car_list);
} }
"trash" => { "trash" => {
// Collection optimization // Collection optimization
// Identifies cars that can be safely removed // Identifies cars that can be safely removed
let my_data = load_my_cars_data(); let my_data = load_my_cars_data();
let car_list = parse_all_times(&my_data.owned).await; let ctx = AnalysisContext::new();
print_trash(&car_list, &my_data.owned); let car_list = parse_all_times(&my_data.owned, &ctx).await;
print_trash(&ctx, &car_list, &my_data.owned);
} }
"details" => { "details" => {
// Comprehensive data export // Comprehensive data export
// Outputs all timing data for external analysis // Outputs all timing data for external analysis
let my_data = load_my_cars_data(); let my_data = load_my_cars_data();
parse_all_times(&my_data.owned).await; let ctx = AnalysisContext::new();
print_best_times(); parse_all_times(&my_data.owned, &ctx).await;
print_best_times(&ctx);
} }
"track" => { "track" => {
// Track-specific analysis // Track-specific analysis
@ -180,11 +137,12 @@ async fn main() {
} else { } else {
// Safe to unwrap here due to above check // Safe to unwrap here due to above check
let my_data = load_my_cars_data(); 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 track = args.get(2).unwrap();
let condition = args.get(3).unwrap(); let condition = args.get(3).unwrap();
let rq = args.get(4).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 /// # Arguments
/// ///
/// * `ctx` - Analysis context containing the best cars data
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file /// * `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 /// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
/// removing potentially useful higher-end cars). /// removing potentially useful higher-end cars).
/// ///
@ -216,11 +177,11 @@ async fn main() {
/// # Note /// # Note
/// Prize cars are excluded from trash recommendations as they may have collection value /// Prize cars are excluded from trash recommendations as they may have collection value
/// beyond their performance characteristics. /// beyond their performance characteristics.
fn print_trash(car_list: &[Car], owned: &HashSet<String>) { fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet<String>) {
// Extract GUIDs of all best performing cars across all tracks and conditions // Extract GUIDs of all best performing cars across all tracks and conditions
// Lock is scoped to avoid holding it during file I/O below // Lock is scoped to avoid holding it during file I/O below
let best_cars: HashSet<_> = { let best_cars: HashSet<_> = {
let guard = BEST_CARS.lock().unwrap(); let guard = ctx.best_cars.lock().unwrap();
guard guard
.values() .values()
.flat_map(|records| records.iter().map(|record| record.guid.clone())) .flat_map(|records| records.iter().map(|record| record.guid.clone()))
@ -248,98 +209,6 @@ fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
} }
} }
/// 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<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
wishlist: HashSet<String>,
}
/// 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<String> = 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<String> = 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 /// 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 /// 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 /// # Arguments
/// ///
/// * `ctx` - Analysis context containing the best cars data
/// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json` /// * `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 /// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
fn print_wishlist_stale(wishlist: &HashSet<String>, car_list: &[Car]) { fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet<String>, car_list: &[Car]) {
// Collect GUIDs of all cars that are currently best for at least one combination // 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 // Lock is scoped to avoid holding it during file I/O below
let current_best_guids: HashSet<String> = { let current_best_guids: HashSet<String> = {
let best_cars = BEST_CARS.lock().unwrap(); let best_cars = ctx.best_cars.lock().unwrap();
best_cars best_cars
.values() .values()
.flat_map(|records| records.iter().map(|r| r.guid.clone())) .flat_map(|records| records.iter().map(|r| r.guid.clone()))
@ -405,10 +275,10 @@ fn print_wishlist_stale(wishlist: &HashSet<String>, car_list: &[Car]) {
/// # Note /// # Note
/// This generates a large amount of output data and is primarily used for /// This generates a large amount of output data and is primarily used for
/// external analysis or when detailed performance data is required. /// 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 // Scope the lock to only the data extraction phase
let sorted_data: Vec<((String, u8, String), Vec<BestCarRecord>)> = { let sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = {
let best_cars = BEST_CARS.lock().unwrap(); let best_cars = ctx.best_cars.lock().unwrap();
// Collect keys and sort them by RQ and Track ID for consistent output // Collect keys and sort them by RQ and Track ID for consistent output
let mut sorted_keys: Vec<_> = best_cars.keys().collect(); let mut sorted_keys: Vec<_> = best_cars.keys().collect();
@ -445,7 +315,9 @@ fn print_best_times() {
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `ctx` - Analysis context containing the best cars data
/// * `filter_out_owned` - If true, excludes cars already owned by the user from the output /// * `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 /// # Output Format
/// ```csv /// ```csv
@ -458,10 +330,10 @@ fn print_best_times() {
/// # Note /// # Note
/// The count represents how many times this car achieved the best time across different /// The count represents how many times this car achieved the best time across different
/// track/RQ/condition combinations, indicating overall performance quality. /// track/RQ/condition combinations, indicating overall performance quality.
fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) { fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet<String>) {
// Scope the lock to only the data extraction phase // Scope the lock to only the data extraction phase
let car_count: HashMap<String, (BestCarRecord, usize)> = { let car_count: HashMap<String, (BestCarRecord, usize)> = {
let best_cars = BEST_CARS.lock().unwrap(); let best_cars = ctx.best_cars.lock().unwrap();
let mut counts: HashMap<String, (BestCarRecord, usize)> = HashMap::new(); let mut counts: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
// Count occurrences of each car across all tracks and conditions // Count occurrences of each car across all tracks and conditions
@ -507,6 +379,7 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `ctx` - Analysis context containing the best cars data
/// * `track` - The track identifier (e.g., "laguna", "monaco") /// * `track` - The track identifier (e.g., "laguna", "monaco")
/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt") /// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt")
/// * `rq` - RQ level limit (cars with this RQ or lower will be considered) /// * `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<String>) {
/// # Note /// # Note
/// If no car is found for the specified combination, it may indicate that no data /// If no car is found for the specified combination, it may indicate that no data
/// is available for that specific track/condition/RQ combination. /// is available for that specific track/condition/RQ combination.
fn print_best_car(track: &String, condition: &String, rq: &String) { fn print_best_car(ctx: &AnalysisContext, track: &String, condition: &String, rq: &String) {
let best_cars = BEST_CARS.lock().unwrap(); let best_cars = ctx.best_cars.lock().unwrap();
let key = ( let key = (
track.clone(), track.clone(),
rq.parse::<u8>().unwrap_or(115), // Default to max RQ if parsing fails rq.parse::<u8>().unwrap_or(MAX_RQ), // Default to max RQ if parsing fails
condition.clone(), 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. /// 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, /// 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 /// # Arguments
/// ///
/// * `car` - The car data structure containing metadata about the car to process /// * `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 /// # 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 /// 3. **Track Processing**: Extracts timing data for each track and surface condition
/// 4. **Validation**: Filters out invalid times and downvoted entries /// 4. **Validation**: Filters out invalid times and downvoted entries
/// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl) /// 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 /// # 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 /// This function is designed to be called concurrently for multiple cars to improve
/// overall processing speed when analyzing large car databases. /// overall processing speed when analyzing large car databases.
async fn parse_car_times(car: Car, my_cars: &HashSet<String>) { async fn parse_car_times(car: Car, my_cars: &HashSet<String>, ctx: &AnalysisContext) {
// Construct file path for this car's timing data // Construct file path for this car's timing data
let file_name = car.rid.to_owned() + ".json"; let file_name = car.rid.to_owned() + ".json";
let local_file_path = "times/".to_owned() + file_name.as_str(); 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<String>) {
// Apply quality filters before processing // Apply quality filters before processing
if time > 1.2 && no_down_votes { if time > 1.2 && no_down_votes {
update_best_car( update_best_car(
translate_track_name(cleaned_track_id), &ctx.best_cars,
&translate_track_name(cleaned_track_id),
car.rq, car.rq,
translate_track_type(track_type), translate_track_type(track_type),
BestCarRecord { BestCarRecord {
@ -656,14 +532,15 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
} }
} }
} }
/// 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 /// 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. /// function determines whether the new record represents an improvement or tie.
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `best_cars` - Shared mutable reference to the best cars map
/// * `track_id` - The track identifier /// * `track_id` - The track identifier
/// * `car_rq` - The base RQ level of the car /// * `car_rq` - The base RQ level of the car
/// * `surface` - The track surface condition (Dry, Wet, etc.) /// * `surface` - The track surface condition (Dry, Wet, etc.)
@ -671,7 +548,7 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
/// ///
/// # Algorithm /// # 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 /// 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) /// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate)
/// 3. **Worse Time**: Ignore the new record /// 3. **Worse Time**: Ignore the new record
@ -681,11 +558,20 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
/// The RQ range iteration allows cars to compete at higher RQ levels than their base /// 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. /// rating, simulating how cars might perform when upgraded or in different matchups.
/// This provides more comprehensive analysis across the entire performance spectrum. /// 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) { fn update_best_car(
let mut best_cars = BEST_CARS.lock().unwrap(); best_cars: &Arc<std::sync::Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
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) let mut best_cars = best_cars.lock().unwrap();
for rq in car_rq..=115 {
// 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()); let key = (track_id.to_string(), rq, surface.to_string());
match best_cars.get_mut(&key) { 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 // New record is faster than current best
// Replace entire list - only keep the fastest car // Replace entire list - only keep the fastest car
*existing_list = vec![new_record.clone()]; *existing_list = vec![new_record.clone()];
} else if (new_record.time - best_time).abs() < f64::EPSILON { } else if (new_record.time - best_time).abs() < TIME_TOLERANCE {
// Times are essentially equal (within floating point precision) // Times are essentially equal (within tolerance)
// This is a tie - add the car only if not already present // This is a tie - add the car only if not already present
// Prevents duplicate entries for the same car // Prevents duplicate entries for the same car
if !existing_list if !existing_list
@ -726,8 +612,9 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
/// ///
/// # Performance /// # Performance
/// ///
/// Uses Tokio's spawn system to create one task per car, enabling concurrent /// Uses Tokio's spawn system with a semaphore to create one task per car,
/// file I/O and JSON parsing operations. The function waits for all tasks to /// 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. /// complete before returning.
/// ///
/// # Note /// # Note
@ -738,18 +625,24 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
/// # Returns /// # Returns
/// ///
/// Returns the full car list so callers can reuse it without re-reading the file. /// Returns the full car list so callers can reuse it without re-reading the file.
async fn parse_all_times(my_cars: &HashSet<String>) -> Vec<Car> { async fn parse_all_times(my_cars: &HashSet<String>, ctx: &AnalysisContext) -> Vec<Car> {
// Use Arc<HashSet> for efficient O(1) lookups across concurrent tasks // Use Arc<HashSet> for efficient O(1) lookups across concurrent tasks
let my_cars = Arc::new(my_cars.clone()); let my_cars = Arc::new(my_cars.clone());
let car_list = get_car_list(); let car_list = get_car_list();
let mut handles = vec![]; 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 // Create one async task per car for concurrent processing
for car in car_list.iter() { for car in car_list.iter() {
let my_cars = Arc::clone(&my_cars); let my_cars = Arc::clone(&my_cars);
let best_cars = Arc::clone(&ctx.best_cars);
let car = car.clone(); let car = car.clone();
let sem = Arc::clone(&semaphore);
let handle = task::spawn(async move { 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); handles.push(handle);
} }
@ -908,136 +801,10 @@ async fn get_cars_file() {
println!("Fetching 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; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs;
use tempfile::tempdir;
/// Test data for mocking car database
fn create_test_car_data() -> Vec<Value> {
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] #[test]
fn test_split_once() { fn test_split_once() {
@ -1087,42 +854,9 @@ mod tests {
assert_eq!(translate_track_type("99"), "99"); 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] #[test]
fn test_update_best_car_logic() { fn test_update_best_car_logic() {
// Note: We can't easily test the global BEST_CARS mutex in unit tests // Verify test data is constructed correctly
// 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
let test_record = BestCarRecord { let test_record = BestCarRecord {
car_rid: "Test_Car".to_string(), car_rid: "Test_Car".to_string(),
brand: "TestBrand".to_string(), brand: "TestBrand".to_string(),
@ -1135,7 +869,6 @@ mod tests {
guid: "test_guid".to_string(), guid: "test_guid".to_string(),
}; };
// Verify test data is constructed correctly
assert_eq!(test_record.time, 100.0); assert_eq!(test_record.time, 100.0);
assert_eq!(test_record.rq, 85); assert_eq!(test_record.rq, 85);
assert_eq!(test_record.config, "111"); assert_eq!(test_record.config, "111");
@ -1148,8 +881,7 @@ mod tests {
#[test] #[test]
fn test_rq_range_analysis() { fn test_rq_range_analysis() {
// Reset global state let ctx = AnalysisContext::new();
*BEST_CARS.lock().unwrap() = HashMap::new();
let test_record = BestCarRecord { let test_record = BestCarRecord {
car_rid: "Test_Car".to_string(), car_rid: "Test_Car".to_string(),
@ -1163,13 +895,13 @@ mod tests {
guid: "test_guid".to_string(), guid: "test_guid".to_string(),
}; };
// Test that car appears in RQ range from its base RQ to 115 // Test that car appears in RQ range from its base RQ to MAX_RQ
update_best_car("laguna", 85, "Dry", test_record); 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 // Should appear at RQ 85, 90, 95, ..., MAX_RQ
for rq in 85..=115 { for rq in 85..=MAX_RQ {
let key = ("laguna".to_string(), rq, "Dry".to_string()); let key = ("laguna".to_string(), rq, "Dry".to_string());
assert!( assert!(
best_cars.contains_key(&key), best_cars.contains_key(&key),
@ -1186,9 +918,6 @@ mod tests {
// - Valid times should be processed normally // - Valid times should be processed normally
// - This logic is tested indirectly through the parsing functions // - 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 valid_time = 112.45; // Valid time
let invalid_time = 1.0; // Invalid time (too low) 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"); assert!(!name.is_empty(), "Condition name should not be empty");
} }
} }
}
/// Translates internal track identifiers to human-readable track names #[test]
/// fn test_time_tolerance_constant() {
/// Converts compact internal track codes (like "laguna", "monaco", "testBowl") // Verify the time tolerance is 5ms as required
/// to their full, descriptive names that appear in the Top Drives game interface. // We test this indirectly by verifying tie behavior
/// let ctx = AnalysisContext::new();
/// # Arguments
/// let record1 = BestCarRecord {
/// * `track_code` - The internal track identifier from the timing data car_rid: "Car_A".to_string(),
/// brand: "Brand".to_string(),
/// # Returns only_name: "Model".to_string(),
/// year: 2020,
/// Returns the full track name as displayed in the game: rq: 85,
/// - "laguna" → "Laguna Seca" config: "111".to_string(),
/// - "monaco" → "Monaco Streets" time: 100.0,
/// - "testBowl" → "Test Bowl" owned: false,
/// - And hundreds more track mappings guid: "guid_a".to_string(),
/// };
/// # Coverage
/// let record2 = BestCarRecord {
/// Contains mappings for all known tracks in the Top Drives game, including car_rid: "Car_B".to_string(),
/// special tracks like drag strips, rally stages, and circuit races. brand: "Brand".to_string(),
/// only_name: "Model".to_string(),
/// # Note year: 2020,
/// rq: 85,
/// If an unknown track code is provided, the original code is returned config: "111".to_string(),
/// unchanged, allowing the system to gracefully handle new tracks. time: 100.003, // Within 0.005 tolerance
fn translate_track_name(track_code: &str) -> &str { owned: false,
match track_code { guid: "guid_b".to_string(),
"drag100b" => "0-100-0mph", };
"drag100" => "0-100mph",
"drag120" => "0-120mph", let record3 = BestCarRecord {
"drag124" => "0-124mph", car_rid: "Car_C".to_string(),
"drag150b" => "0-150-0mph", brand: "Brand".to_string(),
"drag150" => "0-150mph", only_name: "Model".to_string(),
"drag170" => "0-170mph", year: 2020,
"drag200" => "0-200mph", rq: 85,
"drag60b" => "0-60-0mph", config: "111".to_string(),
"drag60" => "0-60mph", time: 100.006, // Outside 0.005 tolerance
"drag62" => "0-62mph", owned: false,
"mile1" => "1 Mile Drag", guid: "guid_c".to_string(),
"mile1r" => "1 Mile Drag R", };
"mile2" => "1/2 Mile Drag",
"mile4" => "1/4 Mile Drag", update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
"mile4r" => "1/4 Mile Drag R", update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
"drag100150" => "100-150mph", update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
"drag30130" => "30-130mph",
"drag50150" => "50-150mph", let best_cars = ctx.best_cars.lock().unwrap();
"drag75125" => "75-125mph", let key = ("laguna".to_string(), 85, "Dry".to_string());
"airplaneHangars" => "Airplane Hangars", let records = best_cars.get(&key).unwrap();
"airplaneSlalom" => "Airplane Slalom",
"smallRoad" => "Back Road", // record1 and record2 should both be present (within tolerance)
"butte" => "Butte", // record3 should not be added (outside tolerance, and worse time)
"canyonButte" => "Canyon Butte", assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance");
"lookout" => "Lookout", assert!(records.iter().any(|r| r.car_rid == "Car_A"));
"canyonLookout" => "Canyon Lookout", assert!(records.iter().any(|r| r.car_rid == "Car_B"));
"carPark" => "Car Park", assert!(!records.iter().any(|r| r.car_rid == "Car_C"));
"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,
} }
} }

141
track_names.json Normal file
View file

@ -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"
}

View file

@ -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