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:
parent
6a68469239
commit
89fa69e3c8
14 changed files with 874 additions and 2037 deletions
60
CHANGELOG.md
60
CHANGELOG.md
|
|
@ -14,17 +14,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- User guide with command examples and troubleshooting
|
||||
- Data structures documentation for JSON formats
|
||||
- Development setup guide with dependency explanations
|
||||
- `track_names.json` — external config file mapping track codes to human-readable names (loaded at compile time via `include_str!`)
|
||||
- `full_script` command for running fetch + all analysis commands in sequence
|
||||
- Daily workflow documentation with output file descriptions (`best.csv`, `details.csv`, `add.csv`, `remove.csv`, `trash.csv`, `sorted.csv`, `very_best.csv`, `wishlist.csv`)
|
||||
|
||||
### Improved
|
||||
### Changed
|
||||
- `translate_track_name()` in `lib.rs` now loads track name mappings from `track_names.json` instead of a hardcoded match statement
|
||||
- Magic number 115 replaced with `const MAX_RQ: u8 = 113` in `records.rs` for clarity and maintainability
|
||||
- `parse_all_times()` now uses a semaphore (limit 20) for bounded concurrency instead of unbounded task spawning
|
||||
- Empty test bodies removed from `records.rs`
|
||||
- Enhanced error handling throughout the application
|
||||
- Better progress reporting during data fetching
|
||||
- More descriptive command-line help text
|
||||
- Improved CSV output formatting
|
||||
|
||||
### Removed
|
||||
- **Club Module**: Removed `src/club.rs` binary (topdrives.club is no longer maintained)
|
||||
- **scraper dependency**: Removed `scraper` crate from `Cargo.toml` (was only used by club module)
|
||||
- **tracks.txt**: Removed track definitions file (only used by club binary)
|
||||
- Club module `Car` struct (separate struct with `class`, `upgrade` fields)
|
||||
|
||||
## [0.2.0] - 2024-12-XX
|
||||
|
||||
### Added
|
||||
- **Club Module**: Alternative data source using web scraping from topdrives.club
|
||||
- **Track-Specific Analysis**: New `track` command for targeted recommendations
|
||||
- **Enhanced Output**: CSV file generation for analysis results
|
||||
- **Improved Concurrency**: Better handling of multiple simultaneous downloads
|
||||
|
|
@ -64,33 +76,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Data Processing
|
||||
- **[0.1.0]** Basic JSON parsing and analysis
|
||||
- **[0.2.0]** Enhanced error handling and data validation
|
||||
- **[Unreleased]** Improved caching and incremental updates
|
||||
- **[Unreleased]** Track name mappings externalized to `track_names.json`
|
||||
|
||||
### Performance
|
||||
- **[0.1.0]** Single-threaded processing
|
||||
- **[0.2.0]** Concurrent downloads with rate limiting
|
||||
- **[Unreleased]** Memory optimization and streaming processing
|
||||
- **[Unreleased]** Bounded concurrency via semaphore (limit 20); `MAX_RQ` constant replaces magic number
|
||||
|
||||
### User Interface
|
||||
- **[0.1.0]** Basic command-line interface
|
||||
- **[0.2.0]** Enhanced output formatting and CSV export
|
||||
- **[Unreleased]** Comprehensive documentation and help system
|
||||
- **[Unreleased]** `full_script` command for complete daily workflow
|
||||
|
||||
### Data Sources
|
||||
- **[0.1.0]** JSON-based records from community repository
|
||||
- **[0.2.0]** Added HTML scraping from topdrives.club
|
||||
- **[Unreleased]** Multiple data source integration
|
||||
- **[Unreleased]** Removed club scraping (topdrives.club no longer maintained)
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Upgrading to Unreleased from 0.2.0
|
||||
|
||||
**Breaking Changes:**
|
||||
1. **Club module removed**: `cargo run --bin club` is no longer available
|
||||
2. **`scraper` dependency removed**: No longer needed without club module
|
||||
3. **`tracks.txt` removed**: Track names now in `track_names.json`
|
||||
4. **`translate_track_name()` changed**: Now loads from `track_names.json` instead of hardcoded match
|
||||
5. **Magic number 115 → `MAX_RQ` (113)**: RQ range analysis now uses `MAX_RQ = 113` constant
|
||||
|
||||
**New Features Available:**
|
||||
1. **`full_script` command**: Run fetch + all analysis in one command
|
||||
2. **Additional output files**: `add.csv`, `remove.csv`, `sorted.csv`, `very_best.csv`, `wishlist.csv`
|
||||
3. **External track name config**: `track_names.json` can be updated without code changes
|
||||
|
||||
### Upgrading from 0.1.0 to 0.2.0
|
||||
|
||||
**No breaking changes** - the upgrade is fully backwards compatible.
|
||||
|
||||
**New Features Available:**
|
||||
1. **Club Module**: Alternative data source with `cargo run --bin club`
|
||||
2. **Track Command**: Specific track analysis with `cargo run --bin records -- track`
|
||||
3. **CSV Output**: Automatic generation of analysis files
|
||||
1. **Track Command**: Specific track analysis with `cargo run --bin records -- track`
|
||||
2. **CSV Output**: Automatic generation of analysis files
|
||||
|
||||
**Performance Improvements:**
|
||||
- Faster data fetching with concurrent downloads
|
||||
|
|
@ -99,8 +124,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## Deprecation Notices
|
||||
|
||||
### Future Removals
|
||||
- **None currently planned**
|
||||
### Removed in Unreleased
|
||||
- **Club Module**: `src/club.rs` and `cargo run --bin club` — topdrives.club is no longer maintained
|
||||
- **`scraper` dependency**: Removed from `Cargo.toml`
|
||||
- **`tracks.txt`**: Replaced by `track_names.json`
|
||||
|
||||
### Deprecated Features
|
||||
- **None currently deprecated**
|
||||
|
|
@ -122,7 +149,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Data Processing Speed
|
||||
- **Initial Load**: ~2-3 minutes (downloads 1000+ files)
|
||||
- **Subsequent Runs**: <30 seconds (uses cached data)
|
||||
- **Concurrent Operations**: 20 simultaneous downloads
|
||||
- **Concurrent Operations**: 20 simultaneous downloads (semaphore bounded)
|
||||
|
||||
### Memory Usage
|
||||
- **Base Application**: ~50MB
|
||||
|
|
@ -132,7 +159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Network Efficiency
|
||||
- **Compression**: Efficient HTTP compression
|
||||
- **Connection Reuse**: Persistent HTTP connections
|
||||
- **Rate Limiting**: Respects server constraints
|
||||
- **Rate Limiting**: Semaphore-bounded concurrency (limit 20)
|
||||
|
||||
## Known Issues and Limitations
|
||||
|
||||
|
|
@ -150,15 +177,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## Future Roadmap
|
||||
|
||||
### Planned Features (Unreleased)
|
||||
- **Web Interface**: Browser-based GUI for non-technical users
|
||||
- **Historical Analysis**: Track performance changes over time
|
||||
- **Mobile App**: iOS/Android companion application
|
||||
- **Cloud Sync**: Synchronize collection across devices
|
||||
- **Advanced Analytics**: Statistical analysis and trend detection
|
||||
|
||||
### Potential Enhancements
|
||||
- **Real-time Updates**: Live data synchronization
|
||||
- **Social Features**: Share collections and recommendations
|
||||
- **Customization**: User-defined analysis parameters
|
||||
- **Export Options**: Multiple file formats (PDF, Excel)
|
||||
|
||||
|
|
@ -184,11 +207,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Data Sources
|
||||
- **Top Drives Community**: Primary timing data source
|
||||
- **topdrives.club**: Alternative data source
|
||||
- **GitHub Contributors**: Track and car database maintenance
|
||||
|
||||
---
|
||||
|
||||
**Changelog Format**: Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
|
||||
**Versioning**: Follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) principles
|
||||
**Last Updated**: 2024-12-XX
|
||||
**Last Updated**: 2026-05-06
|
||||
|
|
|
|||
309
Cargo.lock
generated
309
Cargo.lock
generated
|
|
@ -32,12 +32,6 @@ version = "3.20.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
|
|
@ -86,40 +80,6 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -131,27 +91,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
|
|
@ -225,16 +164,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
|
|
@ -323,24 +252,6 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
|
|
@ -405,18 +316,6 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
|
|
@ -717,37 +616,6 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -788,12 +656,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
|
|
@ -872,58 +734,6 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -945,12 +755,6 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
|
|
@ -985,21 +789,6 @@ version = "6.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -1136,21 +925,6 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
|
|
@ -1174,25 +948,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -1253,15 +1008,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
|
|
@ -1278,12 +1024,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -1312,31 +1052,6 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -1408,17 +1123,6 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
|
|
@ -1496,7 +1200,6 @@ version = "0.2.0"
|
|||
dependencies = [
|
||||
"futures",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
|
@ -1578,12 +1281,6 @@ version = "1.0.24"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
|
@ -1608,12 +1305,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
|
|||
|
|
@ -3,16 +3,15 @@ name = "top_drives"
|
|||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "club"
|
||||
path = "src/club.rs"
|
||||
[lib]
|
||||
name = "top_drives"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "records"
|
||||
path = "src/records.rs"
|
||||
|
||||
[dependencies]
|
||||
scraper = "0.23.1"
|
||||
reqwest = "0.12.15"
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
serde_json = "1.0.140"
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -17,6 +17,7 @@ A comprehensive Rust-based tool for analyzing car performance data in the Top Dr
|
|||
- [Quick Start](#quick-start)
|
||||
- [Usage Guide](#usage-guide)
|
||||
- [Commands Reference](#commands-reference)
|
||||
- [Daily Workflow](#daily-workflow)
|
||||
- [Data Sources](#data-sources)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
|
|
@ -148,6 +149,36 @@ The analyzer supports multiple track conditions:
|
|||
| `details` | Show detailed timing data for all tracks | None |
|
||||
| `track` | Show best car for specific track | `<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
|
||||
|
||||
### Car Data
|
||||
|
|
@ -160,6 +191,11 @@ The analyzer supports multiple track conditions:
|
|||
- **Coverage**: All tracks and conditions in Top Drives
|
||||
- **Format**: Structured track definitions with conditions
|
||||
|
||||
### Track Names
|
||||
- **Source**: `track_names.json` (included in repository)
|
||||
- **Format**: JSON mapping track codes to human-readable names
|
||||
- **Loaded**: At compile time via `include_str!`
|
||||
|
||||
### User Collection
|
||||
- **File**: `my_cars.json`
|
||||
- **Format**: Top Drives game export format
|
||||
|
|
@ -172,10 +208,11 @@ The analyzer supports multiple track conditions:
|
|||
top_drives/
|
||||
├── src/
|
||||
│ ├── records.rs # Main analyzer binary
|
||||
│ └── club.rs # Alternative data source
|
||||
│ └── lib.rs # Shared library (track name translations)
|
||||
├── times/ # Car performance data (1000+ files)
|
||||
├── cars_repo.json # Car database
|
||||
├── tracks_repo.json # Track definitions
|
||||
├── track_names.json # Track code → name mappings
|
||||
├── my_cars.json # Your car collection
|
||||
└── Cargo.toml # Rust project configuration
|
||||
```
|
||||
|
|
@ -184,15 +221,20 @@ top_drives/
|
|||
- **reqwest**: HTTP client for data fetching
|
||||
- **serde_json**: JSON parsing and serialization
|
||||
- **tokio**: Asynchronous runtime
|
||||
- **scraper**: HTML parsing for alternative data source
|
||||
- **futures**: Async utility combinators
|
||||
- **once_cell**: Lazy static initialization
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Project Structure
|
||||
The project consists of two main binaries:
|
||||
The project consists of a single binary with a shared library:
|
||||
|
||||
1. **records**: Primary analysis tool using JSON data
|
||||
2. **club**: Alternative tool scraping topdrives.club
|
||||
1. **records**: Primary analysis tool using JSON data (`src/records.rs`)
|
||||
2. **lib**: Shared library with track name translations (`src/lib.rs`)
|
||||
|
||||
### Key Constants
|
||||
|
||||
- `MAX_RQ: u8 = 113` — Maximum RQ level used in analysis (defined in `records.rs`)
|
||||
|
||||
### Key Data Structures
|
||||
|
||||
|
|
@ -232,9 +274,8 @@ cargo build
|
|||
# Release build
|
||||
cargo build --release
|
||||
|
||||
# Run specific binary
|
||||
# Run the analyzer
|
||||
cargo run --bin records
|
||||
cargo run --bin club
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
|
@ -252,6 +293,8 @@ Track definitions are stored in `tracks_repo.json`. Each track includes:
|
|||
- Supported conditions
|
||||
- Track type classification
|
||||
|
||||
Track name mappings are stored in `track_names.json` and loaded at compile time.
|
||||
|
||||
### Adding Car Data
|
||||
New car performance data should be submitted to the upstream repository following their contribution guidelines.
|
||||
|
||||
|
|
|
|||
164
docs/API.md
164
docs/API.md
|
|
@ -23,7 +23,7 @@ async fn main()
|
|||
**Purpose**: Command-line interface and operation dispatcher
|
||||
|
||||
**Arguments**: Command line arguments
|
||||
- `args[1]`: Command (`fetch`, `best`, `missing`, `trash`, `details`, `track`)
|
||||
- `args[1]`: Command (`fetch`, `best`, `missing`, `trash`, `details`, `track`, `full_script`)
|
||||
|
||||
**Commands**:
|
||||
- `fetch`: Download all data files
|
||||
|
|
@ -32,6 +32,7 @@ async fn main()
|
|||
- `trash`: Show removable cars
|
||||
- `details`: Export all timing data
|
||||
- `track`: Track-specific analysis
|
||||
- `full_script`: Run fetch + all analysis commands
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
|
|
@ -148,7 +149,7 @@ async fn parse_all_times()
|
|||
|
||||
**Purpose**: Coordinates parallel processing of car timing files
|
||||
|
||||
**Concurrency**: One async task per car file
|
||||
**Concurrency**: One async task per car file, bounded by a semaphore (limit 20)
|
||||
**Synchronization**: Waits for all tasks to complete
|
||||
|
||||
##### `update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord)`
|
||||
|
|
@ -161,12 +162,12 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
**Purpose**: Implements core best-car selection algorithm
|
||||
|
||||
**RQ Range Analysis**:
|
||||
- Tests car from base RQ up to maximum (115)
|
||||
- Tests car from base RQ up to `MAX_RQ` (113)
|
||||
- Compares against existing best times
|
||||
- Updates records for improved performance
|
||||
|
||||
**Logic**:
|
||||
1. For each RQ level from car_rq to 115:
|
||||
1. For each RQ level from car_rq to `MAX_RQ` (113):
|
||||
2. If new time is better → replace record
|
||||
3. If new time ties → add to list (if not duplicate)
|
||||
4. If new time is worse → ignore
|
||||
|
|
@ -205,7 +206,7 @@ async fn fetch_times()
|
|||
|
||||
**Purpose**: Batch download of car timing data
|
||||
|
||||
**Rate Limiting**: 20 concurrent downloads maximum
|
||||
**Rate Limiting**: 20 concurrent downloads maximum (semaphore)
|
||||
**Progress**: Shows download progress for each file
|
||||
|
||||
##### `fetch_car(rid: String)`
|
||||
|
|
@ -258,6 +259,10 @@ async fn get_file(file_url: &str, local_file_path: &str)
|
|||
- Error handling with graceful failure
|
||||
- Progress indication through errors
|
||||
|
||||
### Shared Library (`src/lib.rs`)
|
||||
|
||||
Common utilities shared across the project.
|
||||
|
||||
##### `translate_track_name(track_code: &str) -> &str`
|
||||
**Converts track codes to human-readable names**
|
||||
|
||||
|
|
@ -267,7 +272,9 @@ fn translate_track_name(track_code: &str) -> &str
|
|||
|
||||
**Purpose**: User-friendly track name display
|
||||
|
||||
**Coverage**: 200+ track mappings
|
||||
**Implementation**: Loads mappings from `track_names.json` at compile time via `include_str!`
|
||||
|
||||
**Coverage**: All tracks defined in `track_names.json`
|
||||
**Fallback**: Returns original code if not found
|
||||
|
||||
##### `translate_track_type(track_code: &str) -> &str`
|
||||
|
|
@ -299,89 +306,6 @@ fn split_prefix_postfix<'a>(input: &'a str, delimiter: &'a str) -> Option<(&'a s
|
|||
split_prefix_postfix("laguna_a00", "_a") // Some(("laguna", "00"))
|
||||
```
|
||||
|
||||
### Club Module (`src/club.rs`)
|
||||
|
||||
Alternative data source using web scraping from topdrives.club.
|
||||
|
||||
#### Public Functions
|
||||
|
||||
##### `main()`
|
||||
**Entry point for club scraper**
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main()
|
||||
```
|
||||
|
||||
**Purpose**: Web scraping interface for live data
|
||||
|
||||
**Arguments**:
|
||||
- Command line arguments for RQ and track filtering
|
||||
- `-d` flag for detailed output
|
||||
|
||||
##### `get_page(url: &str) -> String`
|
||||
**Fetches HTML content from URLs**
|
||||
|
||||
```rust
|
||||
async fn get_page(url: &str) -> String
|
||||
```
|
||||
|
||||
**Purpose**: Downloads web pages for scraping
|
||||
|
||||
**Security**: Accepts invalid SSL certificates for compatibility
|
||||
|
||||
##### `parse_best_car(text: String) -> Car`
|
||||
**Extracts car data from HTML content**
|
||||
|
||||
```rust
|
||||
fn parse_best_car(text: String) -> Car
|
||||
```
|
||||
|
||||
**Purpose**: Parses HTML tables to extract car information
|
||||
|
||||
**CSS Selectors**:
|
||||
- `.carList__item-manufacturer`: Car brand
|
||||
- `.carList__item-model`: Car model
|
||||
- `.carList__item-year`: Model year
|
||||
- `.carList__item-level`: Class and RQ (complex parsing)
|
||||
- `.carList__item-upgrade`: Upgrade configuration
|
||||
|
||||
##### `parse_tracks(file_name: &str) -> HashMap<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
|
||||
|
||||
### Records Module Data Types
|
||||
|
|
@ -436,22 +360,15 @@ type TripletKey = (String, u8, String);
|
|||
// Track ID RQ Level Surface Condition
|
||||
```
|
||||
|
||||
### Club Module Data Types
|
||||
|
||||
#### `Car`
|
||||
**Scraped car data from website**
|
||||
### Constants
|
||||
|
||||
#### `MAX_RQ`
|
||||
```rust
|
||||
struct Car {
|
||||
class: String, // Class letter (S, A, B, etc.)
|
||||
rq: u8, // Rarity level
|
||||
manufacturer: String, // Brand name
|
||||
model: String, // Model name
|
||||
year: u16, // Model year
|
||||
upgrade: String, // Upgrade config
|
||||
}
|
||||
const MAX_RQ: u8 = 113;
|
||||
```
|
||||
|
||||
**Purpose**: Maximum RQ level for range analysis. Replaces former magic number 115 with a named constant.
|
||||
|
||||
## Global State
|
||||
|
||||
### `BEST_CARS` Cache
|
||||
|
|
@ -533,7 +450,7 @@ fetch_times().await;
|
|||
- **Per Car**: ~1-2KB metadata + timing data
|
||||
|
||||
### Concurrency
|
||||
- **Downloads**: 20 concurrent connections max
|
||||
- **Downloads**: 20 concurrent connections max (semaphore bounded)
|
||||
- **Processing**: One task per car file
|
||||
- **Thread Safety**: Mutex-protected shared state
|
||||
|
||||
|
|
@ -544,41 +461,16 @@ fetch_times().await;
|
|||
2. Implement analysis function
|
||||
3. Update help documentation
|
||||
|
||||
### Adding New Data Sources
|
||||
1. Implement new module (similar to `club.rs`)
|
||||
2. Add data source selection logic
|
||||
3. Update configuration system
|
||||
### Adding New Tracks
|
||||
1. Update `tracks_repo.json` with new track definitions
|
||||
2. Add track name mapping to `track_names.json`
|
||||
3. Track names are compiled in at build time
|
||||
|
||||
### Custom Analysis Types
|
||||
1. Create new output formatting functions
|
||||
2. Add aggregation algorithms
|
||||
3. Extend data structures as needed
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_car_times() {
|
||||
// Test implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
- Full workflow testing
|
||||
- File I/O validation
|
||||
- Network request mocking
|
||||
|
||||
### Benchmarking
|
||||
- Parse performance measurement
|
||||
- Memory usage profiling
|
||||
- Concurrent operation testing
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Network Security
|
||||
|
|
@ -643,12 +535,14 @@ cargo flamegraph --bin records -- best
|
|||
- **Future**: Semantic versioning maintained
|
||||
|
||||
### Breaking Changes
|
||||
- None currently planned
|
||||
- Advance notice provided for API changes
|
||||
- Deprecation warnings for removed features
|
||||
- Club module (`src/club.rs`) has been removed
|
||||
- `scraper` dependency removed
|
||||
- `tracks.txt` removed (replaced by `track_names.json`)
|
||||
- Magic number 115 replaced with `const MAX_RQ: u8 = 113`
|
||||
- `translate_track_name()` now loads from `track_names.json` instead of hardcoded match
|
||||
|
||||
---
|
||||
|
||||
**API Version**: 0.2.0
|
||||
**Last Updated**: 2024-12-XX
|
||||
**Last Updated**: 2026-05-06
|
||||
**Compatibility**: Rust 1.70.0+
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## System Overview
|
||||
|
||||
The Top Drives Records Analyzer consists of two main components working together to provide comprehensive car performance analysis for the Top Drives racing game.
|
||||
The Top Drives Records Analyzer consists of a main analysis binary and a shared library working together to provide comprehensive car performance analysis for the Top Drives racing game.
|
||||
|
||||
## Component Architecture
|
||||
|
||||
|
|
@ -16,6 +16,9 @@ The Top Drives Records Analyzer consists of two main components working together
|
|||
- **Performance Analysis**: Compares timing data across RQ ranges and track conditions
|
||||
- **Collection Management**: Identifies owned cars and optimization opportunities
|
||||
|
||||
#### Key Constants
|
||||
- `MAX_RQ: u8 = 113` — Maximum RQ level used for range analysis (replaces former magic number 115)
|
||||
|
||||
#### Data Flow
|
||||
|
||||
```
|
||||
|
|
@ -32,28 +35,17 @@ User Collection (my_cars.json)
|
|||
Output: CSV/Terminal Results
|
||||
```
|
||||
|
||||
### 2. Club Module (`src/club.rs`)
|
||||
### 2. Shared Library (`src/lib.rs`)
|
||||
|
||||
**Alternative data source** that scrapes live data from topdrives.club website.
|
||||
**Common utilities** shared across the project.
|
||||
|
||||
#### Key Responsibilities
|
||||
- **Web Scraping**: Fetches HTML content from topdrives.club
|
||||
- **HTML Parsing**: Extracts car performance data using CSS selectors
|
||||
- **Real-time Data**: Provides fresh data without pre-downloaded files
|
||||
- **Track Name Translation**: Converts track codes to human-readable names via `translate_track_name()`
|
||||
- **Track Type Translation**: Converts condition codes to surface descriptions via `translate_track_type()`
|
||||
|
||||
#### Data Flow
|
||||
|
||||
```
|
||||
topdrives.club Website
|
||||
↓
|
||||
HTML Content
|
||||
↓
|
||||
CSS Selector Parsing
|
||||
↓
|
||||
Car Data Extraction
|
||||
↓
|
||||
Output: Terminal Results
|
||||
```
|
||||
#### Configuration
|
||||
- Track name mappings are loaded from `track_names.json` at compile time using `include_str!`
|
||||
- This external config file maps track codes (e.g., `"laguna"`) to human-readable names (e.g., `"Laguna Seca"`)
|
||||
|
||||
## Data Structures
|
||||
|
||||
|
|
@ -69,7 +61,7 @@ type TripletKey = (String, u8, String);
|
|||
**Purpose**: Composite key for uniquely identifying track conditions
|
||||
**Usage**: HashMap key for storing best car records
|
||||
|
||||
#### Car (Records Module)
|
||||
#### Car
|
||||
```rust
|
||||
struct Car {
|
||||
rid: String, // Repository ID
|
||||
|
|
@ -97,18 +89,6 @@ struct BestCarRecord {
|
|||
}
|
||||
```
|
||||
|
||||
#### Car (Club Module)
|
||||
```rust
|
||||
struct Car {
|
||||
class: String, // Class letter (S, A, B, etc.)
|
||||
rq: u8, // Rarity level
|
||||
manufacturer: String, // Brand
|
||||
model: String, // Model
|
||||
year: u16, // Year
|
||||
upgrade: String, // Upgrade config
|
||||
}
|
||||
```
|
||||
|
||||
## Global State Management
|
||||
|
||||
### BEST_CARS Cache
|
||||
|
|
@ -127,7 +107,7 @@ static BEST_CARS: Lazy<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>
|
|||
The system analyzes car performance across multiple RQ brackets:
|
||||
|
||||
1. **Base RQ**: Car's natural rarity level
|
||||
2. **RQ Range**: Tests from car's RQ up to maximum (115)
|
||||
2. **RQ Range**: Tests from car's RQ up to `MAX_RQ` (113)
|
||||
3. **Cross-RQ Competition**: Allows cars to compete above their base level
|
||||
|
||||
**Example**: A RQ 85 car might perform well at RQ 90-95 levels against higher-rated cars
|
||||
|
|
@ -142,9 +122,9 @@ Multiple validation layers ensure data quality:
|
|||
|
||||
### Concurrent Processing
|
||||
|
||||
#### Records Module
|
||||
#### Bounded Concurrency Pattern
|
||||
- **Task Spawning**: One async task per car file
|
||||
- **Rate Limiting**: Semaphore limits concurrent downloads (20 max)
|
||||
- **Semaphore**: Limits concurrent operations to 20 (bounded concurrency)
|
||||
- **Error Isolation**: Individual car failures don't stop batch processing
|
||||
|
||||
#### Synchronization
|
||||
|
|
@ -157,6 +137,7 @@ Multiple validation layers ensure data quality:
|
|||
```
|
||||
├── cars_repo.json # Car database from repository
|
||||
├── tracks_repo.json # Track definitions
|
||||
├── track_names.json # Track code → name mappings (compiled in)
|
||||
├── my_cars.json # User's car collection (game export)
|
||||
└── times/ # Individual car timing files
|
||||
├── Ferrari_F40_1987.json
|
||||
|
|
@ -167,8 +148,13 @@ Multiple validation layers ensure data quality:
|
|||
### Output Files
|
||||
```
|
||||
├── best.csv # Best cars analysis results
|
||||
├── missing.csv # Missing recommended cars
|
||||
└── details.csv # Detailed timing data
|
||||
├── details.csv # Detailed timing data
|
||||
├── add.csv # Cars to add to wishlist
|
||||
├── remove.csv # Cars to remove from wishlist
|
||||
├── trash.csv # Owned cars safe to remove
|
||||
├── sorted.csv # Best cars sorted by count
|
||||
├── very_best.csv # Best cars with count ≥ 10
|
||||
└── wishlist.csv # Missing cars already on wishlist
|
||||
```
|
||||
|
||||
## Performance Characteristics
|
||||
|
|
@ -178,12 +164,7 @@ Multiple validation layers ensure data quality:
|
|||
- **Memory Usage**: Moderate (loads all timing data into memory)
|
||||
- **Accuracy**: High (comprehensive dataset)
|
||||
- **Update Frequency**: Manual refresh required
|
||||
|
||||
### Club Module
|
||||
- **Startup Time**: Slow (web scraping required)
|
||||
- **Memory Usage**: Low (processes one track at a time)
|
||||
- **Accuracy**: Variable (depends on website structure)
|
||||
- **Update Frequency**: Real-time on each run
|
||||
- **Concurrency**: Bounded at 20 simultaneous operations via semaphore
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
|
|
@ -195,13 +176,12 @@ Multiple validation layers ensure data quality:
|
|||
### User Feedback
|
||||
- **Progress Indication**: Shows fetching/download progress
|
||||
- **Error Messages**: Clear descriptions of what went wrong
|
||||
- **Fallback Options**: Multiple data sources available
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Tracks
|
||||
1. Update `tracks_repo.json` with new track definitions
|
||||
2. Add track name mappings in `translate_track_name()`
|
||||
2. Add track name mapping in `track_names.json`
|
||||
3. Add condition mappings in `translate_track_type()`
|
||||
|
||||
### Adding New Analysis Types
|
||||
|
|
@ -209,17 +189,12 @@ Multiple validation layers ensure data quality:
|
|||
2. Add command parsing in main function
|
||||
3. Update help text and documentation
|
||||
|
||||
### Custom Data Sources
|
||||
1. Implement new scraping module (similar to club.rs)
|
||||
2. Add data source selection logic
|
||||
3. Update configuration system
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Network Access
|
||||
- **HTTPS Only**: All external requests use HTTPS
|
||||
- **Certificate Validation**: Accepts invalid certificates for compatibility
|
||||
- **Rate Limiting**: Respects server limits with controlled concurrency
|
||||
- **Rate Limiting**: Respects server limits with controlled concurrency (semaphore limit of 20)
|
||||
|
||||
### Local File Access
|
||||
- **Read Only**: No modification of game files
|
||||
|
|
@ -229,7 +204,7 @@ Multiple validation layers ensure data quality:
|
|||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
1. **Caching Layer**: Cache web scraping results
|
||||
1. **Caching Layer**: Cache parsed results between runs
|
||||
2. **Database Backend**: Replace in-memory HashMap with persistent storage
|
||||
3. **Configuration Files**: External configuration for analysis parameters
|
||||
4. **GUI Interface**: Web-based interface for non-technical users
|
||||
|
|
|
|||
|
|
@ -175,6 +175,48 @@ This document describes the JSON data structures used by the Top Drives Records
|
|||
- Used for track-specific analysis queries
|
||||
- Supports condition filtering
|
||||
|
||||
### 5. Track Names Configuration (`track_names.json`)
|
||||
|
||||
**Purpose**: Maps internal track codes to human-readable track names.
|
||||
|
||||
**Source**: Included in the repository, loaded at compile time via `include_str!`
|
||||
|
||||
**Structure**:
|
||||
```json
|
||||
{
|
||||
"laguna": "Laguna Seca",
|
||||
"monaco": "Monaco Streets",
|
||||
"testBowl": "Test Bowl",
|
||||
"dealsGap": "Deals Gap"
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions**:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| *key* | String | Internal track code (e.g., `"laguna"`) |
|
||||
| *value* | String | Human-readable track name (e.g., `"Laguna Seca"`) |
|
||||
|
||||
**Usage in Application**:
|
||||
- Loaded by `translate_track_name()` in `src/lib.rs`
|
||||
- Compiled into the binary at build time
|
||||
- Fallback: Returns original track code if no mapping found
|
||||
|
||||
## Application Constants
|
||||
|
||||
### `MAX_RQ`
|
||||
|
||||
```rust
|
||||
const MAX_RQ: u8 = 113;
|
||||
```
|
||||
|
||||
**Purpose**: Defines the maximum RQ level used in analysis range calculations.
|
||||
|
||||
**Usage**: When evaluating car performance, each car is tested from its base RQ up to `MAX_RQ`. This replaces the former magic number 115, providing a named constant for clarity and easy adjustment.
|
||||
|
||||
**Location**: Defined in `src/records.rs`
|
||||
|
||||
## Data Processing Pipeline
|
||||
|
||||
### 1. Data Acquisition Phase
|
||||
|
|
@ -226,6 +268,11 @@ if let Some(configurations) = json_data["data"].as_object() {
|
|||
- Process: Split on `"_a"` delimiter
|
||||
- Output: `("laguna", "00")` (track name, condition code)
|
||||
|
||||
**Track Name Translation:**
|
||||
- Track codes are looked up in `track_names.json` (compiled into binary)
|
||||
- Example: `"laguna"` → `"Laguna Seca"`
|
||||
- Fallback: Unknown codes returned as-is
|
||||
|
||||
**Condition Code Translation:**
|
||||
- `"00"` → `"Dry"`
|
||||
- `"01"` → `"Wet"`
|
||||
|
|
@ -255,7 +302,7 @@ BestCarRecord {
|
|||
```
|
||||
|
||||
**RQ Range Analysis:**
|
||||
- Test each car from its base RQ up to 115
|
||||
- Test each car from its base RQ up to `MAX_RQ` (113)
|
||||
- Compare against existing best times
|
||||
- Update records if better performance found
|
||||
|
||||
|
|
@ -303,7 +350,7 @@ BestCarRecord {
|
|||
- User collection: Small, loaded once
|
||||
|
||||
**Optimization Strategies**:
|
||||
- Concurrent processing of car files
|
||||
- Concurrent processing of car files with bounded concurrency (semaphore limit of 20)
|
||||
- Rate-limited downloads (20 concurrent max)
|
||||
- Streaming JSON parsing where possible
|
||||
|
||||
|
|
@ -315,7 +362,7 @@ BestCarRecord {
|
|||
3. **HashMap Operations**: Frequent insertions with RQ range checking
|
||||
|
||||
**Optimizations Applied**:
|
||||
1. **Concurrency**: One task per car file
|
||||
1. **Bounded Concurrency**: Semaphore limits concurrent operations to 20
|
||||
2. **Connection Pooling**: Reuse HTTP connections
|
||||
3. **Early Filtering**: Invalid times rejected quickly
|
||||
|
||||
|
|
@ -361,8 +408,8 @@ BestCarRecord {
|
|||
|
||||
**Track Name Issues**:
|
||||
- **Symptom**: Unknown track codes in output
|
||||
- **Cause**: Outdated track definitions
|
||||
- **Solution**: Update `tracks_repo.json`
|
||||
- **Cause**: Missing mapping in `track_names.json`
|
||||
- **Solution**: Add new mapping to `track_names.json`
|
||||
|
||||
### Data Validation Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ serde_json = "1.0.140"
|
|||
- Parse `my_cars.json` (user collection)
|
||||
- Parse individual car timing files
|
||||
- Parse `tracks_repo.json` (track definitions)
|
||||
- Parse `track_names.json` (track name mappings)
|
||||
|
||||
**Performance**: Zero-copy parsing where possible
|
||||
|
||||
|
|
@ -205,26 +206,9 @@ once_cell = "1.20.2"
|
|||
- Thread-safe access during concurrent parsing
|
||||
- Avoid expensive computation on every access
|
||||
|
||||
#### 6. `scraper` - HTML Parsing (Club Module Only)
|
||||
```toml
|
||||
scraper = "0.23.1"
|
||||
```
|
||||
|
||||
**Purpose**: HTML parsing and CSS selector engine for web scraping
|
||||
|
||||
**Features Used**:
|
||||
- CSS selector parsing with `Selector::parse()`
|
||||
- HTML document parsing with `Html::parse_document()`
|
||||
- Element selection and text extraction
|
||||
|
||||
**Why Needed**:
|
||||
- Alternative data source for club.rs module
|
||||
- Parse car data from topdrives.club HTML tables
|
||||
- Extract performance information from web pages
|
||||
|
||||
### Development Dependencies
|
||||
|
||||
#### 7. Build Tools and Quality
|
||||
#### 6. Build Tools and Quality
|
||||
|
||||
**Recommended additions for development:**
|
||||
```toml
|
||||
|
|
@ -289,9 +273,9 @@ name = "top_drives"
|
|||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "club"
|
||||
path = "src/club.rs"
|
||||
[lib]
|
||||
name = "top_drives"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "records"
|
||||
|
|
@ -300,7 +284,6 @@ path = "src/records.rs"
|
|||
|
||||
**Binary Configuration:**
|
||||
- **records**: Main analysis tool (JSON-based)
|
||||
- **club**: Alternative scraper (web-based)
|
||||
|
||||
**Dependency Versions:**
|
||||
- All dependencies pinned to specific versions for reproducibility
|
||||
|
|
@ -342,7 +325,7 @@ RUST_BACKTRACE=1 cargo run
|
|||
```
|
||||
|
||||
**Concurrency Tuning:**
|
||||
- Default: 20 concurrent downloads (configurable via Semaphore)
|
||||
- Default: 20 concurrent operations (bounded via Semaphore)
|
||||
- Thread count: Automatic based on CPU cores
|
||||
- Memory usage: ~100-200MB for full dataset
|
||||
|
||||
|
|
@ -471,7 +454,6 @@ cargo build --release
|
|||
**Binary Location:**
|
||||
```
|
||||
target/release/records # Main analyzer
|
||||
target/release/club # Club scraper
|
||||
```
|
||||
|
||||
**Cross-Platform Building:**
|
||||
|
|
|
|||
|
|
@ -160,8 +160,8 @@ Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with tim
|
|||
```
|
||||
|
||||
**Track name format:**
|
||||
- Use internal names: `laguna`, `monaco`, `testBowl`
|
||||
- Full mappings in `translate_track_name()` function
|
||||
- Use display names: `Laguna Seca`, `Monaco`, `Test Bowl`
|
||||
- Full mappings loaded from `track_names.json`
|
||||
|
||||
**Condition options:**
|
||||
- `Dry` - Standard asphalt
|
||||
|
|
@ -169,6 +169,43 @@ Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with tim
|
|||
- `Dirt` - Off-road tracks
|
||||
- `Snow` - Winter conditions
|
||||
|
||||
## Daily Workflow
|
||||
|
||||
The recommended daily workflow uses the `full_script` command, which runs `fetch` followed by all analysis commands in a single pipeline:
|
||||
|
||||
```bash
|
||||
cargo run --bin records -- full_script
|
||||
```
|
||||
|
||||
### What `full_script` Does
|
||||
|
||||
1. **Fetches** fresh data from the community repository
|
||||
2. **Runs** all analysis commands sequentially
|
||||
3. **Generates** all output CSV files
|
||||
|
||||
### Output Files
|
||||
|
||||
| File | Purpose | Format |
|
||||
|------|---------|--------|
|
||||
| `best.csv` | Ranked list of best cars — evaluate if a car is worth keeping | `RQ,Brand,Model,Year,Count` |
|
||||
| `details.csv` | Detailed timing data for all tracks/conditions — find best config for a car | `RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time` |
|
||||
| `add.csv` | Cars to add to wishlist — missing cars not yet wishlisted, prefixed with `+` | `+RQ,Brand,Model,Year` |
|
||||
| `remove.csv` | Cars to remove from wishlist — stale wishlist entries, prefixed with `-` | `-RQ,Brand,Model,Year` |
|
||||
| `trash.csv` | Owned cars that can be safely removed | `RQ,Brand,Model,Year` |
|
||||
| `sorted.csv` | Best cars sorted by count (descending) | `RQ,Brand,Model,Year,Count` |
|
||||
| `very_best.csv` | Best cars with count ≥ 10 | `RQ,Brand,Model,Year,Count` |
|
||||
| `wishlist.csv` | Missing cars already on wishlist | `RQ,Brand,Model,Year,Count` |
|
||||
|
||||
### Typical Daily Routine
|
||||
|
||||
1. **Run `full_script`** to get fresh data and full analysis
|
||||
2. **Review `best.csv`** to see how your cars rank overall
|
||||
3. **Check `add.csv`** for cars worth adding to your wishlist (prefixed with `+`)
|
||||
4. **Check `remove.csv`** for wishlist entries to remove (prefixed with `-`)
|
||||
5. **Review `trash.csv`** for owned cars you can safely sell
|
||||
6. **Use `details.csv`** to find the optimal tune config for a specific car
|
||||
7. **Check `very_best.csv`** for the absolute top performers (count ≥ 10)
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### RQ Range Analysis
|
||||
|
|
@ -241,16 +278,24 @@ The tool generates CSV files for external analysis:
|
|||
- Import into spreadsheet for sorting/filtering
|
||||
- Track performance across time
|
||||
|
||||
#### missing.csv
|
||||
- Cars to acquire
|
||||
- Prioritize by count and RQ relevance
|
||||
- Plan acquisition strategy
|
||||
|
||||
#### details.csv
|
||||
- Comprehensive timing database
|
||||
- Advanced analysis and research
|
||||
- Historical performance tracking
|
||||
|
||||
#### add.csv / remove.csv
|
||||
- Wishlist management
|
||||
- `+` prefix indicates cars to add
|
||||
- `-` prefix indicates cars to remove
|
||||
|
||||
#### trash.csv
|
||||
- Cars safe to sell from your collection
|
||||
- Cross-reference with your garage before selling
|
||||
|
||||
#### sorted.csv / very_best.csv
|
||||
- Sorted rankings by versatility
|
||||
- `very_best.csv` filters to count ≥ 10 for top-tier recommendations
|
||||
|
||||
### Spreadsheet Analysis Tips
|
||||
|
||||
**Excel/Google Sheets setup:**
|
||||
|
|
@ -265,7 +310,7 @@ The tool generates CSV files for external analysis:
|
|||
|
||||
#### "Please provide a command"
|
||||
**Problem:** No arguments provided
|
||||
**Solution:** Use `best`, `missing`, `trash`, `details`, or `track`
|
||||
**Solution:** Use `best`, `missing`, `trash`, `details`, `track`, or `full_script`
|
||||
|
||||
#### "Could not read my cars file"
|
||||
**Problem:** `my_cars.json` not found or not readable
|
||||
|
|
@ -303,9 +348,9 @@ The tool generates CSV files for external analysis:
|
|||
## Tips and Best Practices
|
||||
|
||||
### Regular Maintenance
|
||||
1. **Weekly**: Run `fetch` to get latest data
|
||||
2. **Monthly**: Review `missing` cars for new acquisitions
|
||||
3. **Quarterly**: Use `trash` to optimize collection
|
||||
1. **Daily**: Run `full_script` to get latest data and full analysis
|
||||
2. **Weekly**: Review `missing` cars for new acquisitions
|
||||
3. **Monthly**: Use `trash` to optimize collection
|
||||
|
||||
### Data-Driven Decisions
|
||||
1. **Trust the numbers**: Count-based recommendations are data-driven
|
||||
|
|
|
|||
661
src/club.rs
661
src/club.rs
|
|
@ -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
317
src/lib.rs
Normal 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())
|
||||
}
|
||||
675
src/records.rs
675
src/records.rs
|
|
@ -30,71 +30,23 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task;
|
||||
|
||||
use futures::future::join_all;
|
||||
|
||||
use reqwest::get;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Core car information structure containing basic car metadata
|
||||
/// Used throughout the application to represent car entities
|
||||
#[derive(Debug, Clone)]
|
||||
struct Car {
|
||||
/// Unique car identifier used in the records database
|
||||
rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
brand: String,
|
||||
/// Car model name (without brand)
|
||||
only_name: String,
|
||||
/// Car model year
|
||||
year: u16,
|
||||
/// Rarity level (10-113, affects performance brackets)
|
||||
rq: u8,
|
||||
/// Whether this car is only available as a prize car
|
||||
prize: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
guid: String,
|
||||
}
|
||||
use top_drives::{
|
||||
AnalysisContext, BestCarRecord, Car, TripletKey,
|
||||
compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type,
|
||||
};
|
||||
|
||||
/// Represents the best performing car record for a specific track, RQ, and condition
|
||||
/// Contains all necessary information to compare car performance
|
||||
#[derive(Debug, Clone)]
|
||||
struct BestCarRecord {
|
||||
/// Car identifier from the records database
|
||||
car_rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
brand: String,
|
||||
/// Car model name (without brand)
|
||||
only_name: String,
|
||||
/// Car model year
|
||||
year: u16,
|
||||
/// Rarity level of this specific car configuration
|
||||
rq: u8,
|
||||
/// Tune configuration string (e.g., "111", "233", "323")
|
||||
config: String,
|
||||
/// Track time in seconds (lower is better)
|
||||
time: f64,
|
||||
/// Whether the current user owns this car
|
||||
owned: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
guid: String,
|
||||
}
|
||||
/// Type alias for the composite key used to identify unique track conditions
|
||||
/// Consists of: (Track ID, RQ Level, Surface Condition)
|
||||
type TripletKey = (String, u8, String);
|
||||
|
||||
/// Global cache storing the best car records for each track, RQ, and surface combination
|
||||
/// Protected by a Mutex for thread-safe access during concurrent parsing operations
|
||||
/// Uses std::sync::LazyLock for one-time initialization
|
||||
static BEST_CARS: LazyLock<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
/// Maximum RQ level in the Top Drives database.
|
||||
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
|
||||
const MAX_RQ: u8 = 113;
|
||||
|
||||
/// Main entry point for the Top Drives Records Analyzer
|
||||
///
|
||||
|
|
@ -140,37 +92,42 @@ async fn main() {
|
|||
// Standard best cars analysis
|
||||
// Shows cars you own that perform well across tracks
|
||||
let my_data = load_my_cars_data();
|
||||
let _car_list = parse_all_times(&my_data.owned).await;
|
||||
print_best_cars(false, &my_data.wishlist); // false = include owned cars
|
||||
let ctx = AnalysisContext::new();
|
||||
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars
|
||||
}
|
||||
"missing" => {
|
||||
// Missing cars analysis
|
||||
// Shows high-performing cars you don't own
|
||||
// Cars not yet on the wishlist are prefixed with "+"
|
||||
let my_data = load_my_cars_data();
|
||||
let _car_list = parse_all_times(&my_data.owned).await;
|
||||
print_best_cars(true, &my_data.wishlist); // true = filter out owned cars
|
||||
let ctx = AnalysisContext::new();
|
||||
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars
|
||||
}
|
||||
"wishlist" => {
|
||||
// Wishlist review
|
||||
// Shows cars on the wishlist that are no longer among the best
|
||||
let my_data = load_my_cars_data();
|
||||
let car_list = parse_all_times(&my_data.owned).await;
|
||||
print_wishlist_stale(&my_data.wishlist, &car_list);
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list = parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_wishlist_stale(&ctx, &my_data.wishlist, &car_list);
|
||||
}
|
||||
"trash" => {
|
||||
// Collection optimization
|
||||
// Identifies cars that can be safely removed
|
||||
let my_data = load_my_cars_data();
|
||||
let car_list = parse_all_times(&my_data.owned).await;
|
||||
print_trash(&car_list, &my_data.owned);
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list = parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_trash(&ctx, &car_list, &my_data.owned);
|
||||
}
|
||||
"details" => {
|
||||
// Comprehensive data export
|
||||
// Outputs all timing data for external analysis
|
||||
let my_data = load_my_cars_data();
|
||||
parse_all_times(&my_data.owned).await;
|
||||
print_best_times();
|
||||
let ctx = AnalysisContext::new();
|
||||
parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_best_times(&ctx);
|
||||
}
|
||||
"track" => {
|
||||
// Track-specific analysis
|
||||
|
|
@ -180,11 +137,12 @@ async fn main() {
|
|||
} else {
|
||||
// Safe to unwrap here due to above check
|
||||
let my_data = load_my_cars_data();
|
||||
parse_all_times(&my_data.owned).await;
|
||||
let ctx = AnalysisContext::new();
|
||||
parse_all_times(&my_data.owned, &ctx).await;
|
||||
let track = args.get(2).unwrap();
|
||||
let condition = args.get(3).unwrap();
|
||||
let rq = args.get(4).unwrap();
|
||||
print_best_car(track, condition, rq);
|
||||
print_best_car(&ctx, track, condition, rq);
|
||||
}
|
||||
}
|
||||
&_ => {
|
||||
|
|
@ -202,7 +160,10 @@ async fn main() {
|
|||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ctx` - Analysis context containing the best cars data
|
||||
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
|
||||
/// * `owned` - Set of GUIDs the user owns
|
||||
///
|
||||
/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
|
||||
/// removing potentially useful higher-end cars).
|
||||
///
|
||||
|
|
@ -216,11 +177,11 @@ async fn main() {
|
|||
/// # Note
|
||||
/// Prize cars are excluded from trash recommendations as they may have collection value
|
||||
/// beyond their performance characteristics.
|
||||
fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
|
||||
fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet<String>) {
|
||||
// Extract GUIDs of all best performing cars across all tracks and conditions
|
||||
// Lock is scoped to avoid holding it during file I/O below
|
||||
let best_cars: HashSet<_> = {
|
||||
let guard = BEST_CARS.lock().unwrap();
|
||||
let guard = ctx.best_cars.lock().unwrap();
|
||||
guard
|
||||
.values()
|
||||
.flat_map(|records| records.iter().map(|record| record.guid.clone()))
|
||||
|
|
@ -248,98 +209,6 @@ fn print_trash(car_list: &[Car], owned: &HashSet<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
|
||||
///
|
||||
/// Compares the user's wishlist (from `my_cars.json`) against the current best-car
|
||||
|
|
@ -358,13 +227,14 @@ fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
|
|||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ctx` - Analysis context containing the best cars data
|
||||
/// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json`
|
||||
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
|
||||
fn print_wishlist_stale(wishlist: &HashSet<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
|
||||
// Lock is scoped to avoid holding it during file I/O below
|
||||
let current_best_guids: HashSet<String> = {
|
||||
let best_cars = BEST_CARS.lock().unwrap();
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
best_cars
|
||||
.values()
|
||||
.flat_map(|records| records.iter().map(|r| r.guid.clone()))
|
||||
|
|
@ -405,10 +275,10 @@ fn print_wishlist_stale(wishlist: &HashSet<String>, car_list: &[Car]) {
|
|||
/// # Note
|
||||
/// This generates a large amount of output data and is primarily used for
|
||||
/// external analysis or when detailed performance data is required.
|
||||
fn print_best_times() {
|
||||
fn print_best_times(ctx: &AnalysisContext) {
|
||||
// Scope the lock to only the data extraction phase
|
||||
let sorted_data: Vec<((String, u8, String), Vec<BestCarRecord>)> = {
|
||||
let best_cars = BEST_CARS.lock().unwrap();
|
||||
let sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = {
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
|
||||
// Collect keys and sort them by RQ and Track ID for consistent output
|
||||
let mut sorted_keys: Vec<_> = best_cars.keys().collect();
|
||||
|
|
@ -445,7 +315,9 @@ fn print_best_times() {
|
|||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ctx` - Analysis context containing the best cars data
|
||||
/// * `filter_out_owned` - If true, excludes cars already owned by the user from the output
|
||||
/// * `wishlist` - Set of car GUIDs on the user's wishlist
|
||||
///
|
||||
/// # Output Format
|
||||
/// ```csv
|
||||
|
|
@ -458,10 +330,10 @@ fn print_best_times() {
|
|||
/// # Note
|
||||
/// The count represents how many times this car achieved the best time across different
|
||||
/// track/RQ/condition combinations, indicating overall performance quality.
|
||||
fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
|
||||
fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet<String>) {
|
||||
// Scope the lock to only the data extraction phase
|
||||
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();
|
||||
|
||||
// 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
|
||||
///
|
||||
/// * `ctx` - Analysis context containing the best cars data
|
||||
/// * `track` - The track identifier (e.g., "laguna", "monaco")
|
||||
/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt")
|
||||
/// * `rq` - RQ level limit (cars with this RQ or lower will be considered)
|
||||
|
|
@ -519,11 +392,11 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
|
|||
/// # Note
|
||||
/// If no car is found for the specified combination, it may indicate that no data
|
||||
/// is available for that specific track/condition/RQ combination.
|
||||
fn print_best_car(track: &String, condition: &String, rq: &String) {
|
||||
let best_cars = BEST_CARS.lock().unwrap();
|
||||
fn print_best_car(ctx: &AnalysisContext, track: &String, condition: &String, rq: &String) {
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
let key = (
|
||||
track.clone(),
|
||||
rq.parse::<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(),
|
||||
);
|
||||
|
||||
|
|
@ -552,11 +425,13 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
|||
///
|
||||
/// This is the core function responsible for processing individual car performance data.
|
||||
/// It reads the car's JSON file, extracts timing information for each track and condition,
|
||||
/// filters out invalid or downvoted times, and updates the global best cars database.
|
||||
/// filters out invalid or downvoted times, and updates the analysis context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `car` - The car data structure containing metadata about the car to process
|
||||
/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership)
|
||||
/// * `ctx` - Analysis context containing the shared best cars map
|
||||
///
|
||||
/// # Data Processing
|
||||
///
|
||||
|
|
@ -565,7 +440,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
|||
/// 3. **Track Processing**: Extracts timing data for each track and surface condition
|
||||
/// 4. **Validation**: Filters out invalid times and downvoted entries
|
||||
/// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl)
|
||||
/// 6. **Database Update**: Updates the global best cars cache with new records
|
||||
/// 6. **Database Update**: Updates the analysis context with new records
|
||||
///
|
||||
/// # Special Cases
|
||||
///
|
||||
|
|
@ -577,7 +452,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
|||
///
|
||||
/// This function is designed to be called concurrently for multiple cars to improve
|
||||
/// overall processing speed when analyzing large car databases.
|
||||
async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
||||
async fn parse_car_times(car: Car, my_cars: &HashSet<String>, ctx: &AnalysisContext) {
|
||||
// Construct file path for this car's timing data
|
||||
let file_name = car.rid.to_owned() + ".json";
|
||||
let local_file_path = "times/".to_owned() + file_name.as_str();
|
||||
|
|
@ -634,7 +509,8 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
// Apply quality filters before processing
|
||||
if time > 1.2 && no_down_votes {
|
||||
update_best_car(
|
||||
translate_track_name(cleaned_track_id),
|
||||
&ctx.best_cars,
|
||||
&translate_track_name(cleaned_track_id),
|
||||
car.rq,
|
||||
translate_track_type(track_type),
|
||||
BestCarRecord {
|
||||
|
|
@ -656,14 +532,15 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<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
|
||||
/// RQ ranges. For each RQ level from the car's base RQ up to the maximum (115), this
|
||||
/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this
|
||||
/// function determines whether the new record represents an improvement or tie.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `best_cars` - Shared mutable reference to the best cars map
|
||||
/// * `track_id` - The track identifier
|
||||
/// * `car_rq` - The base RQ level of the car
|
||||
/// * `surface` - The track surface condition (Dry, Wet, etc.)
|
||||
|
|
@ -671,7 +548,7 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
///
|
||||
/// # Algorithm
|
||||
///
|
||||
/// For each RQ level from car's RQ to 115:
|
||||
/// For each RQ level from car's RQ to MAX_RQ:
|
||||
/// 1. **Better Time**: If new record is faster than current best, replace the list
|
||||
/// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate)
|
||||
/// 3. **Worse Time**: Ignore the new record
|
||||
|
|
@ -681,11 +558,20 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
/// The RQ range iteration allows cars to compete at higher RQ levels than their base
|
||||
/// rating, simulating how cars might perform when upgraded or in different matchups.
|
||||
/// This provides more comprehensive analysis across the entire performance spectrum.
|
||||
fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord) {
|
||||
let mut best_cars = BEST_CARS.lock().unwrap();
|
||||
fn update_best_car(
|
||||
best_cars: &Arc<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)
|
||||
for rq in car_rq..=115 {
|
||||
let mut best_cars = best_cars.lock().unwrap();
|
||||
|
||||
// Check performance across RQ range from car's base RQ up to MAX_RQ
|
||||
for rq in car_rq..=MAX_RQ {
|
||||
let key = (track_id.to_string(), rq, surface.to_string());
|
||||
|
||||
match best_cars.get_mut(&key) {
|
||||
|
|
@ -696,8 +582,8 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
// New record is faster than current best
|
||||
// Replace entire list - only keep the fastest car
|
||||
*existing_list = vec![new_record.clone()];
|
||||
} else if (new_record.time - best_time).abs() < f64::EPSILON {
|
||||
// Times are essentially equal (within floating point precision)
|
||||
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE {
|
||||
// Times are essentially equal (within tolerance)
|
||||
// This is a tie - add the car only if not already present
|
||||
// Prevents duplicate entries for the same car
|
||||
if !existing_list
|
||||
|
|
@ -726,8 +612,9 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// Uses Tokio's spawn system to create one task per car, enabling concurrent
|
||||
/// file I/O and JSON parsing operations. The function waits for all tasks to
|
||||
/// Uses Tokio's spawn system with a semaphore to create one task per car,
|
||||
/// enabling concurrent file I/O and JSON parsing operations while limiting
|
||||
/// concurrency to 20 parallel tasks. The function waits for all tasks to
|
||||
/// complete before returning.
|
||||
///
|
||||
/// # Note
|
||||
|
|
@ -738,18 +625,24 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
/// # Returns
|
||||
///
|
||||
/// Returns the full car list so callers can reuse it without re-reading the file.
|
||||
async fn parse_all_times(my_cars: &HashSet<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
|
||||
let my_cars = Arc::new(my_cars.clone());
|
||||
let car_list = get_car_list();
|
||||
let mut handles = vec![];
|
||||
|
||||
// Limit concurrent parsing to 20 to avoid excessive resource usage
|
||||
let semaphore = Arc::new(Semaphore::new(20));
|
||||
|
||||
// Create one async task per car for concurrent processing
|
||||
for car in car_list.iter() {
|
||||
let my_cars = Arc::clone(&my_cars);
|
||||
let best_cars = Arc::clone(&ctx.best_cars);
|
||||
let car = car.clone();
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let handle = task::spawn(async move {
|
||||
parse_car_times(car, &my_cars).await;
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await;
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
|
@ -908,136 +801,10 @@ async fn get_cars_file() {
|
|||
println!("Fetching cars file...");
|
||||
get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await;
|
||||
}
|
||||
/// Downloads a file from a URL and saves it locally
|
||||
///
|
||||
/// Generic utility function for fetching files from remote URLs. Used by
|
||||
/// both `get_cars_file()` and `get_tracks_file()` to download the latest
|
||||
/// database files from the repository.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_url` - The remote URL to download from
|
||||
/// * `local_file_path` - Local file path where the downloaded content should be saved
|
||||
///
|
||||
/// # Behavior
|
||||
///
|
||||
/// 1. Removes any existing file at the local path
|
||||
/// 2. Downloads the file from the remote URL
|
||||
/// 3. Saves the content to the local file path
|
||||
/// 4. Reports any errors that occur during the process
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Prints error messages for network failures but does not panic, allowing
|
||||
/// the application to continue with cached data if available.
|
||||
async fn get_file(file_url: &str, local_file_path: &str) {
|
||||
// Remove existing file to ensure fresh download
|
||||
let _ = fs::remove_file(local_file_path);
|
||||
|
||||
match get(file_url).await {
|
||||
Ok(response) => {
|
||||
// Download successful, save to local file
|
||||
fs::write(local_file_path, response.text().await.unwrap())
|
||||
.expect("Unable to write file!");
|
||||
}
|
||||
Err(error) => {
|
||||
// Download failed, print error but don't panic
|
||||
eprintln!("Failed to fetch the file. Status: {}", error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Translates track condition codes to human-readable surface condition names
|
||||
///
|
||||
/// Converts internal track condition codes (like "00", "01", "10") to descriptive
|
||||
/// names that represent the actual racing surface conditions in Top Drives.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track_code` - The internal condition code from the timing data
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a human-readable string describing the track surface condition:
|
||||
/// - "00" → "Dry" (standard asphalt)
|
||||
/// - "01" → "Wet" (rain-affected)
|
||||
/// - "10" → "Dirt" (off-road conditions)
|
||||
/// - And many more specialized conditions
|
||||
///
|
||||
/// # Coverage
|
||||
///
|
||||
/// Supports all known track condition codes used in the Top Drives game,
|
||||
/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet".
|
||||
fn translate_track_type(track_code: &str) -> &str {
|
||||
match track_code {
|
||||
"00" => "Dry",
|
||||
"01" => "Wet",
|
||||
"10" => "Dirt",
|
||||
"11" => "Dirt Wet",
|
||||
"20" => "Gravel",
|
||||
"30" => "Ice",
|
||||
"40" => "Asphalt Dirt",
|
||||
"41" => "Asphalt Dirt Wet",
|
||||
"50" => "Sand",
|
||||
"60" => "Snow",
|
||||
"70" => "Grass",
|
||||
"71" => "Grass Wet",
|
||||
"b0" => "Asphalt Gravel",
|
||||
"c0" => "Asphalt Sand",
|
||||
"c1" => "Asphalt Sand Wet",
|
||||
"d0" => "Asphalt Snow",
|
||||
"e0" => "Sand Dirt",
|
||||
"f0" => "Asphalt Grass Dirt",
|
||||
"g0" => "Ice Snow",
|
||||
"h1" => "Snow Dirt Wet",
|
||||
_ => track_code,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Test data for mocking car database
|
||||
fn create_test_car_data() -> Vec<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]
|
||||
fn test_split_once() {
|
||||
|
|
@ -1087,42 +854,9 @@ mod tests {
|
|||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_car_list() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let cars_file = temp_dir.path().join("test_cars.json");
|
||||
|
||||
// Create test data file
|
||||
let test_cars = create_test_car_data();
|
||||
fs::write(&cars_file, serde_json::to_string(&test_cars).unwrap()).unwrap();
|
||||
|
||||
// Mock the file read by temporarily replacing the file
|
||||
// Note: In a real scenario, we'd use a more sophisticated mocking approach
|
||||
// For now, we'll test the logic indirectly through other functions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_my_car_list() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let user_file = temp_dir.path().join("test_my_cars.json");
|
||||
|
||||
// Create test user data file
|
||||
let test_user_data = create_test_user_data();
|
||||
fs::write(&user_file, serde_json::to_string(&test_user_data).unwrap()).unwrap();
|
||||
|
||||
// Note: Testing this function requires file system mocking
|
||||
// In a production environment, we'd use dependency injection or trait objects
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_logic() {
|
||||
// Note: We can't easily test the global BEST_CARS mutex in unit tests
|
||||
// due to potential poisoning and concurrent access issues.
|
||||
// In a real testing environment, we'd use dependency injection or trait objects.
|
||||
|
||||
// For now, we'll test the logic indirectly through other means
|
||||
// This test serves as documentation of expected behavior
|
||||
|
||||
// Verify test data is constructed correctly
|
||||
let test_record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
|
|
@ -1135,7 +869,6 @@ mod tests {
|
|||
guid: "test_guid".to_string(),
|
||||
};
|
||||
|
||||
// Verify test data is constructed correctly
|
||||
assert_eq!(test_record.time, 100.0);
|
||||
assert_eq!(test_record.rq, 85);
|
||||
assert_eq!(test_record.config, "111");
|
||||
|
|
@ -1148,8 +881,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_rq_range_analysis() {
|
||||
// Reset global state
|
||||
*BEST_CARS.lock().unwrap() = HashMap::new();
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let test_record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
|
|
@ -1163,13 +895,13 @@ mod tests {
|
|||
guid: "test_guid".to_string(),
|
||||
};
|
||||
|
||||
// Test that car appears in RQ range from its base RQ to 115
|
||||
update_best_car("laguna", 85, "Dry", test_record);
|
||||
// Test that car appears in RQ range from its base RQ to MAX_RQ
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record);
|
||||
|
||||
let best_cars = BEST_CARS.lock().unwrap();
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
|
||||
// Should appear at RQ 85, 90, 95, ..., 115
|
||||
for rq in 85..=115 {
|
||||
// Should appear at RQ 85, 90, 95, ..., MAX_RQ
|
||||
for rq in 85..=MAX_RQ {
|
||||
let key = ("laguna".to_string(), rq, "Dry".to_string());
|
||||
assert!(
|
||||
best_cars.contains_key(&key),
|
||||
|
|
@ -1186,9 +918,6 @@ mod tests {
|
|||
// - Valid times should be processed normally
|
||||
// - This logic is tested indirectly through the parsing functions
|
||||
|
||||
// Note: Direct testing of global state is complex due to mutex locking
|
||||
// In a production environment, we'd use mocks or dependency injection
|
||||
|
||||
let valid_time = 112.45; // Valid time
|
||||
let invalid_time = 1.0; // Invalid time (too low)
|
||||
|
||||
|
|
@ -1236,174 +965,62 @@ mod tests {
|
|||
assert!(!name.is_empty(), "Condition name should not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Translates internal track identifiers to human-readable track names
|
||||
///
|
||||
/// Converts compact internal track codes (like "laguna", "monaco", "testBowl")
|
||||
/// to their full, descriptive names that appear in the Top Drives game interface.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track_code` - The internal track identifier from the timing data
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the full track name as displayed in the game:
|
||||
/// - "laguna" → "Laguna Seca"
|
||||
/// - "monaco" → "Monaco Streets"
|
||||
/// - "testBowl" → "Test Bowl"
|
||||
/// - And hundreds more track mappings
|
||||
///
|
||||
/// # Coverage
|
||||
///
|
||||
/// Contains mappings for all known tracks in the Top Drives game, including
|
||||
/// special tracks like drag strips, rally stages, and circuit races.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// If an unknown track code is provided, the original code is returned
|
||||
/// unchanged, allowing the system to gracefully handle new tracks.
|
||||
fn translate_track_name(track_code: &str) -> &str {
|
||||
match track_code {
|
||||
"drag100b" => "0-100-0mph",
|
||||
"drag100" => "0-100mph",
|
||||
"drag120" => "0-120mph",
|
||||
"drag124" => "0-124mph",
|
||||
"drag150b" => "0-150-0mph",
|
||||
"drag150" => "0-150mph",
|
||||
"drag170" => "0-170mph",
|
||||
"drag200" => "0-200mph",
|
||||
"drag60b" => "0-60-0mph",
|
||||
"drag60" => "0-60mph",
|
||||
"drag62" => "0-62mph",
|
||||
"mile1" => "1 Mile Drag",
|
||||
"mile1r" => "1 Mile Drag R",
|
||||
"mile2" => "1/2 Mile Drag",
|
||||
"mile4" => "1/4 Mile Drag",
|
||||
"mile4r" => "1/4 Mile Drag R",
|
||||
"drag100150" => "100-150mph",
|
||||
"drag30130" => "30-130mph",
|
||||
"drag50150" => "50-150mph",
|
||||
"drag75125" => "75-125mph",
|
||||
"airplaneHangars" => "Airplane Hangars",
|
||||
"airplaneSlalom" => "Airplane Slalom",
|
||||
"smallRoad" => "Back Road",
|
||||
"butte" => "Butte",
|
||||
"canyonButte" => "Canyon Butte",
|
||||
"lookout" => "Lookout",
|
||||
"canyonLookout" => "Canyon Lookout",
|
||||
"carPark" => "Car Park",
|
||||
"townSlalom" => "City Slalom",
|
||||
"townSprint" => "City Sprint",
|
||||
"csMed" => "City Streets Medium",
|
||||
"csSmall" => "City Streets Small",
|
||||
"townTour" => "City Tour",
|
||||
"cross" => "Cross-Country",
|
||||
"dealsGap" => "Deals Gap",
|
||||
"dealsGap1" => "Deals Gap Relay 1",
|
||||
"dealsGap2" => "Deals Gap Relay 2",
|
||||
"dealsGap3" => "Deals Gap Relay 3",
|
||||
"dealsGap4" => "Deals Gap Relay 4",
|
||||
"dealsGap5" => "Deals Gap Relay 5",
|
||||
"dealsGapBack" => "Deals Gap Backroads",
|
||||
"desertBig" => "Desert Rally Big",
|
||||
"desertHill" => "Desert Rally Hill",
|
||||
"desertRallyDirt" => "Desert Rally Dirt",
|
||||
"desertSmall" => "Desert Rally Small",
|
||||
"dockAltCirc" => "Dockyard Alternative Circuit",
|
||||
"dockCirc" => "Dockyard Circuit",
|
||||
"dockCity" => "Dockyard City Streets",
|
||||
"dockGforceDrag" => "Dockyard G-Force Drag",
|
||||
"dockKart" => "Dockyard Karting",
|
||||
"dockUdrag" => "Dockyard U-Drag",
|
||||
"dtRoad" => "Dirt Road",
|
||||
"canyonDtRoad" => "Canyon Dirt Road",
|
||||
"fast" => "Fast Circuit",
|
||||
"fastr" => "Fast Circuit R",
|
||||
"fieldSlalom" => "Field Slalom",
|
||||
"figureEight" => "Figure Of Eight",
|
||||
"forestRiver" => "Forest River Crossing",
|
||||
"forest" => "Forest Road",
|
||||
"forestSlalom" => "Forest Slalom",
|
||||
"frozenLake" => "Frozen Lake",
|
||||
"frozenRivSla" => "Frozen River Slalom",
|
||||
"gForce" => "G-Force Test",
|
||||
"gForcer" => "G-Force Test R",
|
||||
"hairpin" => "Hairpin Road",
|
||||
"hClimb" => "Hill Climb",
|
||||
"hClimbr" => "Hill Climb R",
|
||||
"indoorKart" => "Indoor Karting",
|
||||
"kart" => "Karting Circuit",
|
||||
"draglshape" => "L-Shape Drag",
|
||||
"laguna" => "Laguna Seca",
|
||||
"lumberForest" => "Lumber Mill Forest Rally",
|
||||
"lumberRiver" => "Lumber Mill River Rally",
|
||||
"lumberTwisty" => "Lumber Mill Twisty Circuit",
|
||||
"miCircuit" => "Miami Circuit",
|
||||
"miStreets1" => "Miami Streets 1",
|
||||
"miStreets2" => "Miami Streets 2",
|
||||
"miUdrag" => "Miami U-Drag",
|
||||
"miCause" => "Miami Causeway",
|
||||
"miCauseDrag" => "Miami Causeway Drag",
|
||||
"miBridge" => "Miami Bridge",
|
||||
"miFigure" => "Miami Figure Of Eight",
|
||||
"miSlalom" => "Miami Slalom",
|
||||
"miGforce" => "Miami G-Force",
|
||||
"miBeachDrag" => "Miami Beach Drag",
|
||||
"mnGforce" => "Monaco G-Force Test",
|
||||
"mnHairpin" => "Monaco Hairpin",
|
||||
"mnCityNarrow" => "Monaco Narrow Streets",
|
||||
"mnCity" => "Monaco Streets",
|
||||
"mnCityLong" => "Monaco Streets Long",
|
||||
"moto" => "Motocross Track",
|
||||
"mtHairpin" => "Mountain Hairpin",
|
||||
"mtHill" => "Mountain Hill Climb",
|
||||
"mtIncline" => "Mountain Incline Road",
|
||||
"mtSlalom" => "Mountain Slalom",
|
||||
"mtTour" => "Mountain Tour",
|
||||
"mtTwisty" => "Mountain Twisty Road",
|
||||
"nwCircuit" => "New York Circuit",
|
||||
"nwDrag" => "New York Drag",
|
||||
"nwGforce" => "New York G-Force",
|
||||
"nwLoop" => "New York Times Square Loop",
|
||||
"nwCathedral" => "New York Cathedral",
|
||||
"nwTour" => "New York Central Park Tour",
|
||||
"nwSlalom" => "New York Train Slalom",
|
||||
"northloop" => "North Loop",
|
||||
"northloop1" => "North Loop Relay 1",
|
||||
"northloop2" => "North Loop Relay 2",
|
||||
"northloop3" => "North Loop Relay 3",
|
||||
"northloop4" => "North Loop Relay 4",
|
||||
"northloop5" => "North Loop Relay 5",
|
||||
"oceanSlalom" => "Ocean Beach Slalom",
|
||||
"oceanCity" => "Ocean City Streets",
|
||||
"oceanHighway" => "Ocean Highway",
|
||||
"oceanLongDrag" => "Ocean Long Drag",
|
||||
"oceanParking" => "Ocean Parking Lot",
|
||||
"oceanShortDrag" => "Ocean Short Drag",
|
||||
"rallyMed" => "Rallycross Medium Circuit",
|
||||
"rallySmall" => "Rallycross Small Circuit",
|
||||
"riverSprint" => "River Sprint",
|
||||
"runwayDrag" => "Runway Drag",
|
||||
"runwayDragSlalom" => "Runway Drag & Slalom",
|
||||
"runwayLoop" => "Runway Loop",
|
||||
"serviceRoads" => "Service Roads",
|
||||
"slalom" => "Slalom Test",
|
||||
"slalomr" => "Slalom Test R",
|
||||
"speedbump12km" => "Speedbump 1 km",
|
||||
"speedbump1km" => "Speedbump 1/2 km",
|
||||
"testBowl" => "Test Bowl",
|
||||
"testBowlr" => "Test Bowl R",
|
||||
"tokyoBridge" => "Tokyo Bridge",
|
||||
"tokyoDrag" => "Tokyo Drag",
|
||||
"tokyoGforce" => "Tokyo G-Force Test",
|
||||
"tokyoLoop" => "Tokyo Loop",
|
||||
"tokyoOffRamp" => "Tokyo Off-Ramp",
|
||||
"tokyoOverpass" => "Tokyo Overpass",
|
||||
"tCircuit" => "Twisty Circuit",
|
||||
"tCircuitr" => "Twisty Circuit R",
|
||||
"tRoad" => "Twisty Road",
|
||||
"waterDrag" => "Waterfront Drag",
|
||||
_ => track_code,
|
||||
|
||||
#[test]
|
||||
fn test_time_tolerance_constant() {
|
||||
// Verify the time tolerance is 5ms as required
|
||||
// We test this indirectly by verifying tie behavior
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let record1 = BestCarRecord {
|
||||
car_rid: "Car_A".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.0,
|
||||
owned: false,
|
||||
guid: "guid_a".to_string(),
|
||||
};
|
||||
|
||||
let record2 = BestCarRecord {
|
||||
car_rid: "Car_B".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.003, // Within 0.005 tolerance
|
||||
owned: false,
|
||||
guid: "guid_b".to_string(),
|
||||
};
|
||||
|
||||
let record3 = BestCarRecord {
|
||||
car_rid: "Car_C".to_string(),
|
||||
brand: "Brand".to_string(),
|
||||
only_name: "Model".to_string(),
|
||||
year: 2020,
|
||||
rq: 85,
|
||||
config: "111".to_string(),
|
||||
time: 100.006, // Outside 0.005 tolerance
|
||||
owned: false,
|
||||
guid: "guid_c".to_string(),
|
||||
};
|
||||
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
|
||||
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
|
||||
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
let key = ("laguna".to_string(), 85, "Dry".to_string());
|
||||
let records = best_cars.get(&key).unwrap();
|
||||
|
||||
// record1 and record2 should both be present (within tolerance)
|
||||
// record3 should not be added (outside tolerance, and worse time)
|
||||
assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance");
|
||||
assert!(records.iter().any(|r| r.car_rid == "Car_A"));
|
||||
assert!(records.iter().any(|r| r.car_rid == "Car_B"));
|
||||
assert!(!records.iter().any(|r| r.car_rid == "Car_C"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
141
track_names.json
Normal file
141
track_names.json
Normal 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"
|
||||
}
|
||||
275
tracks.txt
275
tracks.txt
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue