Compare commits
10 commits
39d1e1d71b
...
cc73fe62fc
| Author | SHA1 | Date | |
|---|---|---|---|
| cc73fe62fc | |||
| f829ad52a4 | |||
| 3de8bf1f80 | |||
| ead31529a4 | |||
| b85672559b | |||
| 7ac889cbff | |||
| 293a8d7707 | |||
| ec8be9d138 | |||
| 1a844e7aa8 | |||
| 58b8d23e68 |
28 changed files with 4295 additions and 306 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,5 +5,8 @@ cars_repo.json
|
||||||
tracks_repo.json
|
tracks_repo.json
|
||||||
times/
|
times/
|
||||||
*.csv
|
*.csv
|
||||||
|
ca/
|
||||||
|
captures/
|
||||||
|
capture.toml
|
||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Removed
|
### Removed
|
||||||
- `plans/code-review-v2.md` (completed action items)
|
- `plans/code-review-v2.md` (completed action items)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `trash.csv` Config column now lists only the copies to delete: for cars where just one tune is useless (e.g. owned 332 + best-everywhere 323) only the dead tune is shown instead of all owned configs
|
||||||
|
- Cars the records site knows but has no times for (typically new prize cars, returned as `200` with an empty body) no longer abort `fetch` after 3 retries — they are skipped with an info log
|
||||||
|
- Empty timing files are now treated like missing files instead of producing `EOF while parsing a value` parse errors
|
||||||
|
|
||||||
## [0.3.0] - 2026-05-07
|
## [0.3.0] - 2026-05-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
1204
Cargo.lock
generated
1204
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
24
Cargo.toml
24
Cargo.toml
|
|
@ -7,9 +7,16 @@ edition = "2021"
|
||||||
name = "top_drives"
|
name = "top_drives"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# Bin-only (logger init in records.rs); required-features keeps the lib crate's
|
||||||
|
# downstream consumers free of env_logger unless they opt in.
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "records"
|
name = "records"
|
||||||
path = "src/records.rs"
|
path = "src/records.rs"
|
||||||
|
required-features = ["bin-logging"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "capture"
|
||||||
|
path = "src/capture/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = "0.12.15"
|
reqwest = "0.12.15"
|
||||||
|
|
@ -20,13 +27,26 @@ futures = "0.3.31"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = { version = "0.11", optional = true }
|
||||||
|
indicatif = "0.17"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
hudsucker = "0.25"
|
||||||
|
http = "1"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
md-5 = "0.10"
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = ["bin-logging", "test-utils"]
|
||||||
|
bin-logging = ["dep:env_logger"]
|
||||||
test-utils = []
|
test-utils = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
top_drives = { path = ".", features = ["test-utils"] }
|
top_drives = { path = ".", features = ["test-utils"], default-features = false }
|
||||||
tempfile = "3.10.1"
|
tempfile = "3.10.1"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
mockito = "1"
|
mockito = "1"
|
||||||
|
|
|
||||||
51
README.md
51
README.md
|
|
@ -112,18 +112,48 @@ cargo run --bin records -- best --min-count 5
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- trash
|
cargo run --bin records -- trash
|
||||||
```
|
```
|
||||||
Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ.
|
Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars, plus low-value upgrade fodder: unfinished cars whose best config reaches at most a per-RQ-band track count (RQ 10-19: ≤4, RQ 20-29: ≤2, RQ 30-39: ≤1 by default). Sorted by RQ.
|
||||||
|
|
||||||
**Output File:**
|
**Output File:**
|
||||||
```
|
```
|
||||||
trash.csv — Owned cars safe to remove
|
trash.csv — Owned cars safe to remove or use as fusion material
|
||||||
|
```
|
||||||
|
|
||||||
|
The band thresholds derive from a single base value (`--trash-base-count`, default 4); each higher band gets half of the previous one. The tool prints the effective thresholds and a garage-space projection (`user.garageSlots`/`garageSlotsUsed` from the export). When the projected free slots after clean-up fall below `--garage-min-free` (default 20), the base count is raised automatically — and printed — until the target is reached or no more cars qualify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --bin records -- trash --trash-base-count 5 --garage-min-free 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `--trash-base-count 0` to disable the improve-based criteria entirely.
|
||||||
|
|
||||||
|
#### Protect & Storage Analysis
|
||||||
|
```bash
|
||||||
|
cargo run --bin records -- protect
|
||||||
|
```
|
||||||
|
Generates `protect.csv` — cars in the **garage** that are currently **not locked** and are worth locking more copies of — and `storage.csv`, a keep/trash recommendation for cars in the **storage** ("Lager", `state == 0` in the export). Both apply the same dynamic criteria (including garage-space escalation and its printed plan) as the trash command.
|
||||||
|
|
||||||
|
**Output Files:**
|
||||||
|
```
|
||||||
|
protect.csv — Unlocked garage cars worth locking
|
||||||
|
storage.csv — Keep/Trash recommendation for storage cars
|
||||||
|
```
|
||||||
|
|
||||||
|
`protect.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,ToLock`. A car drops off the list once its locked copies cover every best config surviving the RQ-band thresholds; `ToLock` is the number of additional copies worth locking. Cars not best on any track never appear.
|
||||||
|
|
||||||
|
`storage.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,Recommendation`. Cars not best on any track, best only in configs at or below their RQ-band threshold, or with enough locked copies already (surplus unlocked copies are fusion material), are `Trash`; prize cars and RQ ≥ 80 are always `Keep`.
|
||||||
|
|
||||||
|
The trash criteria flags apply here as well:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --bin records -- protect --trash-base-count 5 --garage-min-free 30
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Wishlist Management
|
#### Wishlist Management
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- wishlist
|
cargo run --bin records -- wishlist
|
||||||
```
|
```
|
||||||
Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ.
|
Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. Prize cars are excluded from the ideal wishlist; prize cars still on your actual wishlist appear in `remove.csv`.
|
||||||
|
|
||||||
**Output Files:**
|
**Output Files:**
|
||||||
```
|
```
|
||||||
|
|
@ -146,7 +176,7 @@ cargo run --bin records -- wishlist --max-wishlist 500
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- all
|
cargo run --bin records -- all
|
||||||
```
|
```
|
||||||
Generates all 7 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, and `improve` together, but more efficient.
|
Generates all 10 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, `improve`, and `protect` together, but more efficient.
|
||||||
|
|
||||||
**Output Files:**
|
**Output Files:**
|
||||||
```
|
```
|
||||||
|
|
@ -157,11 +187,13 @@ wishlist.csv — Ideal wishlist (very_best unowned, capped)
|
||||||
add.csv — Cars to add to wishlist
|
add.csv — Cars to add to wishlist
|
||||||
remove.csv — Cars to remove from wishlist
|
remove.csv — Cars to remove from wishlist
|
||||||
improve.csv — Owned cars not yet at final build config
|
improve.csv — Owned cars not yet at final build config
|
||||||
|
protect.csv — Unlocked garage cars worth locking
|
||||||
|
storage.csv — Keep/Trash recommendation for storage cars
|
||||||
```
|
```
|
||||||
|
|
||||||
Supports both `--min-count N` (default 10) and `--max-wishlist M` (default 300):
|
Supports `--min-count N` (default 10), `--max-wishlist M` (default 300), and the trash criteria flags:
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- all --min-count 5 --max-wishlist 500
|
cargo run --bin records -- all --min-count 5 --max-wishlist 500 --trash-base-count 4 --garage-min-free 20
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Detailed Performance Data
|
#### Detailed Performance Data
|
||||||
|
|
@ -202,9 +234,10 @@ The analyzer supports multiple track conditions:
|
||||||
|---------|-------------|-----------|
|
|---------|-------------|-----------|
|
||||||
| `fetch` | Download latest car and track data | None |
|
| `fetch` | Download latest car and track data | None |
|
||||||
| `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` |
|
| `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` |
|
||||||
| `trash` | Generate `trash.csv` (owned cars safe to remove) | None |
|
| `trash` | Generate `trash.csv` (owned cars safe to remove) | `[--trash-base-count N] [--garage-min-free N]` |
|
||||||
|
| `protect` | Generate `protect.csv` (unlocked garage cars) and `storage.csv` (storage keep/trash) | `[--trash-base-count N] [--garage-min-free N]` |
|
||||||
| `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--min-count N] [--max-wishlist M]` |
|
| `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--min-count N] [--max-wishlist M]` |
|
||||||
| `all` | Generate all 7 CSV files in a single pass | `[--min-count N] [--max-wishlist M]` |
|
| `all` | Generate all 10 CSV files in a single pass | `[--min-count N] [--max-wishlist M] [--trash-base-count N] [--garage-min-free N]` |
|
||||||
| `details` | Print detailed timing data for all tracks (stdout) | None |
|
| `details` | Print detailed timing data for all tracks (stdout) | None |
|
||||||
| `track` | Print best car for specific track (stdout) | `<track_name> <condition> <rq>` |
|
| `track` | Print best car for specific track (stdout) | `<track_name> <condition> <rq>` |
|
||||||
| `improve` | Generate `improve.csv` (owned cars not at final build) | None |
|
| `improve` | Generate `improve.csv` (owned cars not at final build) | None |
|
||||||
|
|
@ -215,6 +248,8 @@ The analyzer supports multiple track conditions:
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `--min-count N` | `10` | Minimum number of tracks a car must be best on to qualify as "very best" |
|
| `--min-count N` | `10` | Minimum number of tracks a car must be best on to qualify as "very best" |
|
||||||
| `--max-wishlist M` | `300` | Maximum number of cars on the ideal wishlist |
|
| `--max-wishlist M` | `300` | Maximum number of cars on the ideal wishlist |
|
||||||
|
| `--trash-base-count N` | `4` | Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39 get half/a quarter. `0` disables the criteria |
|
||||||
|
| `--garage-min-free N` | `20` | Minimum projected free garage slots; the trash base count is raised automatically when the projection falls short |
|
||||||
|
|
||||||
## 📅 Daily Workflow
|
## 📅 Daily Workflow
|
||||||
|
|
||||||
|
|
|
||||||
31
capture.toml.example
Normal file
31
capture.toml.example
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Example configuration for `cargo run --bin capture`.
|
||||||
|
# Copy to capture.toml and adjust to your machine; capture.toml is gitignored.
|
||||||
|
#
|
||||||
|
# The capture binary is Windows-only (BlueStacks + adb interception); on Unix
|
||||||
|
# hosts it builds as a stub that exits with an error message.
|
||||||
|
|
||||||
|
[device]
|
||||||
|
# BlueStacks ADB endpoint (BlueStacks Settings -> Advanced -> Android Debug Bridge)
|
||||||
|
serial = "127.0.0.1:5555"
|
||||||
|
# How long to wait for the emulator to come online before giving up
|
||||||
|
wait_secs = 180
|
||||||
|
# Close BlueStacks automatically after the capture finished
|
||||||
|
shutdown_bluestacks = true
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
# Path to adb.exe (empty = auto-detect from LOCALAPPDATA, then PATH)
|
||||||
|
adb = 'C:\Users\<you>\AppData\Local\Android\Sdk\platform-tools\adb.exe'
|
||||||
|
# Path to the BlueStacks player, started automatically if the device is offline
|
||||||
|
bluestacks = 'C:\Program Files\BlueStacks_nxt\HD-Player.exe'
|
||||||
|
# Where the captured garage document is written
|
||||||
|
output = 'my_cars.json'
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
# Local port for the MITM proxy (must be free; close HTTP Toolkit first)
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[capture]
|
||||||
|
# How long to wait for the game to boot and issue the sync request
|
||||||
|
timeout_secs = 300
|
||||||
|
# Per-request timeout when re-issuing the stored sync request (`capture replay`)
|
||||||
|
replay_timeout_secs = 30
|
||||||
23
run_capture.cmd
Normal file
23
run_capture.cmd
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist capture.toml (
|
||||||
|
echo No capture.toml found.
|
||||||
|
echo Copy capture.toml.example to capture.toml and adjust the paths first.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist target\release\capture.exe (
|
||||||
|
echo Building capture binary ...
|
||||||
|
cargo build --release --bin capture
|
||||||
|
if errorlevel 1 (
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
target\release\capture.exe run
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
13
src/capture/main.rs
Normal file
13
src/capture/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() {
|
||||||
|
windows::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!("error: the capture binary is Windows-only (BlueStacks + ADB MITM interception)");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
902
src/capture/windows.rs
Normal file
902
src/capture/windows.rs
Normal file
|
|
@ -0,0 +1,902 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use http::request::Request;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use hudsucker::certificate_authority::RcgenAuthority;
|
||||||
|
use hudsucker::rcgen::{
|
||||||
|
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose,
|
||||||
|
PKCS_ECDSA_P256_SHA256,
|
||||||
|
};
|
||||||
|
use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse};
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const GAME_PACKAGE: &str = "com.hutchgames.cccg";
|
||||||
|
const CA_COMMON_NAME: &str = "Top Drives Capture CA";
|
||||||
|
const GARAGE_MARKER: &[u8] = b"playerDeck";
|
||||||
|
const CONFIG_FILE: &str = "capture.toml";
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct CaptureConfig {
|
||||||
|
device: DeviceConfig,
|
||||||
|
paths: PathsConfig,
|
||||||
|
proxy: ProxyConfig,
|
||||||
|
capture: CaptureSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct DeviceConfig {
|
||||||
|
serial: String,
|
||||||
|
wait_secs: u64,
|
||||||
|
shutdown_bluestacks: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct PathsConfig {
|
||||||
|
adb: String,
|
||||||
|
bluestacks: String,
|
||||||
|
output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct ProxyConfig {
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct CaptureSettings {
|
||||||
|
timeout_secs: u64,
|
||||||
|
replay_timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CaptureConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device: DeviceConfig::default(),
|
||||||
|
paths: PathsConfig::default(),
|
||||||
|
proxy: ProxyConfig::default(),
|
||||||
|
capture: CaptureSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DeviceConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { serial: "127.0.0.1:5555".to_string(), wait_secs: 180, shutdown_bluestacks: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PathsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
adb: detect_adb(),
|
||||||
|
bluestacks: detect_bluestacks(),
|
||||||
|
output: "my_cars.json".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { port: 8080 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CaptureSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { timeout_secs: 300, replay_timeout_secs: 30 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_adb() -> String {
|
||||||
|
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
|
||||||
|
let candidate = format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe");
|
||||||
|
if Path::new(&candidate).exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"adb".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_bluestacks() -> String {
|
||||||
|
const CANDIDATES: [&str; 3] = [
|
||||||
|
r"C:\Program Files\BlueStacks_nxt\HD-Player.exe",
|
||||||
|
r"C:\Program Files\BlueStacks\HD-Player.exe",
|
||||||
|
r"C:\Program Files (x86)\BlueStacks\HD-Player.exe",
|
||||||
|
];
|
||||||
|
CANDIDATES
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| Path::new(candidate).exists())
|
||||||
|
.map(|candidate| candidate.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config(project_root: &Path) -> Result<CaptureConfig> {
|
||||||
|
let config_path = project_root.join(CONFIG_FILE);
|
||||||
|
if config_path.exists() {
|
||||||
|
let text = std::fs::read_to_string(&config_path)?;
|
||||||
|
let config: CaptureConfig = toml::from_str(&text)?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = CaptureConfig::default();
|
||||||
|
let pretty = toml::to_string_pretty(&config)?.replace("\\\\", "/");
|
||||||
|
std::fs::write(&config_path, &pretty)?;
|
||||||
|
println!("created default configuration at {}", config_path.display());
|
||||||
|
println!("review it and adjust paths if needed, then run this command again");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_path(config: &CaptureConfig, project_root: &Path) -> PathBuf {
|
||||||
|
let raw = Path::new(&config.paths.output);
|
||||||
|
if raw.is_absolute() {
|
||||||
|
raw.to_path_buf()
|
||||||
|
} else {
|
||||||
|
project_root.join(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct CapturedRequest {
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CaptureHandler {
|
||||||
|
state: Arc<CaptureState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CaptureState {
|
||||||
|
found: AtomicBool,
|
||||||
|
done: tokio::sync::Notify,
|
||||||
|
project_root: PathBuf,
|
||||||
|
output: PathBuf,
|
||||||
|
pending: std::sync::Mutex<Option<CapturedRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpHandler for CaptureHandler {
|
||||||
|
async fn handle_request(
|
||||||
|
&mut self,
|
||||||
|
_ctx: &HttpContext,
|
||||||
|
mut request: Request<Body>,
|
||||||
|
) -> RequestOrResponse {
|
||||||
|
let host = request_host(&request).to_lowercase();
|
||||||
|
|
||||||
|
if request.method() == http::Method::CONNECT {
|
||||||
|
return request.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_and_query = request
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|value| value.as_str().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
if !is_noise_host(&host) {
|
||||||
|
println!(" {} {}{}", request.method(), host, path_and_query);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers_mut().remove(http::header::ACCEPT_ENCODING);
|
||||||
|
|
||||||
|
if !self.state.found.load(Ordering::SeqCst)
|
||||||
|
&& host.contains("topdrives")
|
||||||
|
&& path_and_query.contains("/sync")
|
||||||
|
{
|
||||||
|
let body_bytes =
|
||||||
|
BodyExt::collect(std::mem::replace(request.body_mut(), Body::from(Vec::new())))
|
||||||
|
.await
|
||||||
|
.map(|collected| collected.to_bytes().to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let template = CapturedRequest {
|
||||||
|
method: request.method().to_string(),
|
||||||
|
url: format!("https://{host}{path_and_query}"),
|
||||||
|
headers: request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| {
|
||||||
|
(
|
||||||
|
name.as_str().to_string(),
|
||||||
|
String::from_utf8_lossy(value.as_bytes()).to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
body: if body_bytes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
*self.state.pending.lock().unwrap() = Some(template);
|
||||||
|
*request.body_mut() = Body::from(body_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_response(
|
||||||
|
&mut self,
|
||||||
|
_ctx: &HttpContext,
|
||||||
|
mut response: http::Response<Body>,
|
||||||
|
) -> http::Response<Body> {
|
||||||
|
if self.state.found.load(Ordering::SeqCst) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let looks_textual = response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(|value| value.contains("json") || value.contains("text"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !looks_textual {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes =
|
||||||
|
match BodyExt::collect(std::mem::replace(response.body_mut(), Body::from(Vec::new())))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(collected) => collected.to_bytes().to_vec(),
|
||||||
|
Err(_) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if contains_marker(&bytes, GARAGE_MARKER) {
|
||||||
|
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||||
|
if json.get("user").is_some() {
|
||||||
|
let template = self.state.pending.lock().unwrap().take();
|
||||||
|
if let Err(error) = persist_capture(
|
||||||
|
&self.state.project_root,
|
||||||
|
&self.state.output,
|
||||||
|
&json,
|
||||||
|
template.as_ref(),
|
||||||
|
) {
|
||||||
|
eprintln!(" failed to save capture: {error}");
|
||||||
|
}
|
||||||
|
self.state.found.store(true, Ordering::SeqCst);
|
||||||
|
self.state.done.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*response.body_mut() = Body::from(bytes);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_host(request: &Request<Body>) -> String {
|
||||||
|
if let Some(host) = request.uri().host() {
|
||||||
|
return host.to_string();
|
||||||
|
}
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.get(http::header::HOST)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split(':').next())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_response(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
method: &http::Method,
|
||||||
|
url: &str,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let mut builder = client
|
||||||
|
.request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, url)
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
for (name, value) in headers.iter() {
|
||||||
|
let lower = name.as_str().to_lowercase();
|
||||||
|
if matches!(
|
||||||
|
lower.as_str(),
|
||||||
|
"host"
|
||||||
|
| "content-length"
|
||||||
|
| "accept-encoding"
|
||||||
|
| "connection"
|
||||||
|
| "proxy-connection"
|
||||||
|
| "keep-alive"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
builder = builder.header(name.as_str(), value.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !body.is_empty() {
|
||||||
|
builder = builder.body(body.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = builder.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("HTTP {status}").into());
|
||||||
|
}
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_capture(
|
||||||
|
project_root: &Path,
|
||||||
|
output: &Path,
|
||||||
|
json: &serde_json::Value,
|
||||||
|
template: Option<&CapturedRequest>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let timestamp =
|
||||||
|
time::OffsetDateTime::now_utc().format(&time::format_description::parse_borrowed::<2>(
|
||||||
|
"[year]-[month]-[day]T[hour]-[minute]-[second]",
|
||||||
|
)?)?;
|
||||||
|
let run_dir = project_root.join("captures").join(×tamp);
|
||||||
|
std::fs::create_dir_all(&run_dir)?;
|
||||||
|
|
||||||
|
let pretty = serde_json::to_string_pretty(json)?;
|
||||||
|
std::fs::write(run_dir.join("my_cars.json"), &pretty)?;
|
||||||
|
|
||||||
|
if output.exists() {
|
||||||
|
let backup = run_dir.join("my_cars.previous.json");
|
||||||
|
std::fs::copy(output, backup)?;
|
||||||
|
}
|
||||||
|
std::fs::write(output, &pretty)?;
|
||||||
|
|
||||||
|
if let Some(template) = template {
|
||||||
|
std::fs::write(
|
||||||
|
project_root.join("captures").join("request.json"),
|
||||||
|
serde_json::to_string_pretty(template)?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" garage data saved to {}", output.display());
|
||||||
|
print_garage_summary(json);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_garage_summary(json: &serde_json::Value) {
|
||||||
|
let social_name = json
|
||||||
|
.pointer("/user/socialName")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("unknown player");
|
||||||
|
let deck = json
|
||||||
|
.get("playerDeck")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|cards| cards.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let locked = json
|
||||||
|
.get("playerDeck")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|cards| {
|
||||||
|
cards
|
||||||
|
.iter()
|
||||||
|
.filter(|card| card.get("locked").and_then(|l| l.as_bool()).unwrap_or(false))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
println!(" player: {social_name}, garage: {deck} cars ({locked} locked)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_noise_host(host: &str) -> bool {
|
||||||
|
const NOISE: [&str; 15] = [
|
||||||
|
"google",
|
||||||
|
"gstatic",
|
||||||
|
"crashlytics",
|
||||||
|
"firebase",
|
||||||
|
"facebook",
|
||||||
|
"fbcdn",
|
||||||
|
"unity",
|
||||||
|
"applovin",
|
||||||
|
"admob",
|
||||||
|
"doubleclick",
|
||||||
|
"vungle",
|
||||||
|
"ironsource",
|
||||||
|
"chartboost",
|
||||||
|
"sentry",
|
||||||
|
"bugsnag",
|
||||||
|
];
|
||||||
|
NOISE.iter().any(|fragment| host.contains(fragment))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_marker(haystack: &[u8], marker: &[u8]) -> bool {
|
||||||
|
haystack.windows(marker.len()).any(|window| window == marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_adb(config: &CaptureConfig, args: &[&str]) -> Result<String> {
|
||||||
|
let output = Command::new(&config.paths.adb)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("failed to run adb ({}): {error}", config.paths.adb))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"adb {} failed: {}",
|
||||||
|
args.join(" "),
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adb(config: &CaptureConfig, args: &[&str]) -> Result<String> {
|
||||||
|
let serial = config.device.serial.as_str();
|
||||||
|
run_adb(config, &[&["-s", serial][..], args].concat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adb_shell(config: &CaptureConfig, command: &str) -> Result<String> {
|
||||||
|
adb(config, &["shell", command])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result<String> {
|
||||||
|
let quoted = format!("su -c '{command}'");
|
||||||
|
adb(config, &["shell", quoted.as_str()])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_listed(config: &CaptureConfig) -> bool {
|
||||||
|
match run_adb(config, &["devices"]) {
|
||||||
|
Ok(devices) => devices
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with(&config.device.serial) && line.contains("device")),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_device_connected(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(config.device.wait_secs);
|
||||||
|
let mut bluestacks_started = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let _ = run_adb(config, &["connect", &config.device.serial]);
|
||||||
|
if device_listed(config) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bluestacks_started {
|
||||||
|
if config.paths.bluestacks.is_empty() || !Path::new(&config.paths.bluestacks).exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"device {} is offline and no usable BlueStacks path is configured ({} in {CONFIG_FILE})",
|
||||||
|
config.device.serial, config.paths.bluestacks
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"device {} offline, starting BlueStacks ({}) ...",
|
||||||
|
config.device.serial, config.paths.bluestacks
|
||||||
|
);
|
||||||
|
Command::new(&config.paths.bluestacks).spawn()?;
|
||||||
|
bluestacks_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"device {} did not come online within {}s",
|
||||||
|
config.device.serial, config.device.wait_secs
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_sdk_level(config: &CaptureConfig) -> Result<u8> {
|
||||||
|
adb_shell(config, "getprop ro.build.version.sdk")?
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "could not read Android SDK level".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_or_create_ca(project_root: &Path) -> Result<(String, String)> {
|
||||||
|
let ca_dir = project_root.join("ca");
|
||||||
|
let cert_path = ca_dir.join("topdrives-ca.pem");
|
||||||
|
let key_path = ca_dir.join("topdrives-ca.key.pem");
|
||||||
|
|
||||||
|
if cert_path.exists() && key_path.exists() {
|
||||||
|
let cert = std::fs::read_to_string(&cert_path)?;
|
||||||
|
let key = std::fs::read_to_string(&key_path)?;
|
||||||
|
return Ok((cert, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&ca_dir)?;
|
||||||
|
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?;
|
||||||
|
let mut params = CertificateParams::new(Vec::new())?;
|
||||||
|
params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME);
|
||||||
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
|
params.key_usages = vec![
|
||||||
|
KeyUsagePurpose::KeyCertSign,
|
||||||
|
KeyUsagePurpose::CrlSign,
|
||||||
|
KeyUsagePurpose::DigitalSignature,
|
||||||
|
];
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
params.not_before = now - time::Duration::days(1);
|
||||||
|
params.not_after = now + time::Duration::days(3650);
|
||||||
|
let certificate = params.self_signed(&key_pair)?;
|
||||||
|
|
||||||
|
let cert_pem = certificate.pem();
|
||||||
|
let key_pem = key_pair.serialize_pem();
|
||||||
|
std::fs::write(&cert_path, &cert_pem)?;
|
||||||
|
std::fs::write(&key_path, &key_pem)?;
|
||||||
|
println!("created new CA certificate at {}", cert_path.display());
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_ca_filename(common_name: &str) -> String {
|
||||||
|
let mut canonical = Vec::new();
|
||||||
|
canonical.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]);
|
||||||
|
canonical.push(0x0C);
|
||||||
|
canonical.push(common_name.len() as u8);
|
||||||
|
canonical.extend_from_slice(common_name.as_bytes());
|
||||||
|
|
||||||
|
let digest = Md5::digest(&canonical);
|
||||||
|
let hash = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]);
|
||||||
|
format!("{hash:08x}.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Result<()> {
|
||||||
|
let filename = android_ca_filename(CA_COMMON_NAME);
|
||||||
|
let store_path = format!("/system/etc/security/cacerts/{filename}");
|
||||||
|
let staging_path = "/data/local/tmp/topdrives-ca.pem";
|
||||||
|
let local_path = project_root.join("ca").join("topdrives-ca.pem");
|
||||||
|
std::fs::write(&local_path, cert_pem)?;
|
||||||
|
|
||||||
|
let presence =
|
||||||
|
adb_shell(config, &format!("test -f {store_path} && echo present || echo absent"))?;
|
||||||
|
if presence.trim() == "present" {
|
||||||
|
println!("CA already present in the system trust store");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
adb(config, &["push", local_path.to_string_lossy().as_ref(), staging_path])?;
|
||||||
|
adb_shell_root(
|
||||||
|
config,
|
||||||
|
"mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mounts = adb_shell(config, "cat /proc/mounts")?;
|
||||||
|
if !mounts.contains("/system/etc/security/cacerts") {
|
||||||
|
adb_shell_root(config, "mount -t tmpfs tmpfs /system/etc/security/cacerts")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
adb_shell_root(
|
||||||
|
config,
|
||||||
|
&format!(
|
||||||
|
"cp /data/local/tmp/cacerts-backup/* /system/etc/security/cacerts/ && cp {staging_path} {store_path} && chmod 644 {store_path}"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let count_text = adb_shell(config, "ls /system/etc/security/cacerts | wc -l")?;
|
||||||
|
let count: u32 = count_text
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "could not count certificates in the store".to_string())?;
|
||||||
|
if count < 100 {
|
||||||
|
return Err(format!("system store looks emptied ({count} certificates), aborting").into());
|
||||||
|
}
|
||||||
|
println!("injected CA into the system trust store as {filename} ({count} certificates total)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_proxy(config: &CaptureConfig, port: u16) -> Result<()> {
|
||||||
|
adb_shell(config, &format!("settings put global http_proxy 127.0.0.1:{port}")).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_device_proxy(config: &CaptureConfig) {
|
||||||
|
let _ = adb_shell(config, "settings put global http_proxy :0");
|
||||||
|
let _ = adb_shell(config, "settings delete global http_proxy");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> {
|
||||||
|
let socket = format!("tcp:{port}");
|
||||||
|
match adb(config, &["reverse", socket.as_str(), socket.as_str()]) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
let route = adb_shell(config, "ip route")?;
|
||||||
|
let gateway = route.lines().find_map(|line| {
|
||||||
|
line.strip_prefix("default via ").and_then(|rest| rest.split(' ').next())
|
||||||
|
});
|
||||||
|
match gateway {
|
||||||
|
Some(gateway) => {
|
||||||
|
println!(
|
||||||
|
"adb reverse failed, falling back to gateway address {gateway}:{port}"
|
||||||
|
);
|
||||||
|
adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}"))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
None => Err("could not set up adb reverse and no default gateway found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_game(config: &CaptureConfig) -> Result<()> {
|
||||||
|
adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"))?;
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
adb(
|
||||||
|
config,
|
||||||
|
&["shell", "monkey", "-p", GAME_PACKAGE, "-c", "android.intent.category.LAUNCHER", "1"],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_proxy(
|
||||||
|
listener: tokio::net::TcpListener,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
handler: CaptureHandler,
|
||||||
|
) -> Result<ProxyFuture> {
|
||||||
|
let key_pair = KeyPair::from_pem(key_pem)?;
|
||||||
|
let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair)?;
|
||||||
|
let provider = hudsucker::rustls::crypto::aws_lc_rs::default_provider();
|
||||||
|
let authority = RcgenAuthority::new(issuer, 1_000, provider);
|
||||||
|
|
||||||
|
let proxy = Proxy::builder()
|
||||||
|
.with_listener(listener)
|
||||||
|
.with_ca(authority)
|
||||||
|
.with_rustls_connector(hudsucker::rustls::crypto::aws_lc_rs::default_provider())
|
||||||
|
.with_http_handler(handler)
|
||||||
|
.build()?;
|
||||||
|
Ok(Box::pin(proxy.start()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyFuture = std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = std::result::Result<(), hudsucker::Error>> + Send>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
async fn command_run(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let project_root = std::env::current_dir()?;
|
||||||
|
let (cert_pem, key_pem) = load_or_create_ca(&project_root)?;
|
||||||
|
|
||||||
|
ensure_device_connected(config)?;
|
||||||
|
let sdk_level = android_sdk_level(config)?;
|
||||||
|
if sdk_level > 28 {
|
||||||
|
println!("warning: Android {sdk_level} detected, the tmpfs CA injection is only tested up to Android 9");
|
||||||
|
}
|
||||||
|
inject_ca(config, &cert_pem, &project_root)?;
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::from((
|
||||||
|
[127, 0, 0, 1],
|
||||||
|
config.proxy.port,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"cannot listen on 127.0.0.1:{} (HTTP Toolkit still running?): {error}",
|
||||||
|
config.proxy.port
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let state = Arc::new(CaptureState {
|
||||||
|
found: AtomicBool::new(false),
|
||||||
|
done: tokio::sync::Notify::new(),
|
||||||
|
project_root: project_root.clone(),
|
||||||
|
output: output_path(config, &project_root),
|
||||||
|
pending: std::sync::Mutex::new(None),
|
||||||
|
});
|
||||||
|
let proxy =
|
||||||
|
build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?;
|
||||||
|
tokio::spawn(proxy);
|
||||||
|
|
||||||
|
set_device_proxy(config, config.proxy.port)?;
|
||||||
|
setup_adb_reverse(config, config.proxy.port)?;
|
||||||
|
println!("proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", config.proxy.port);
|
||||||
|
launch_game(config)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"waiting for the garage request (timeout {}s, Ctrl+C to abort)",
|
||||||
|
config.capture.timeout_secs
|
||||||
|
);
|
||||||
|
let outcome: Result<()> = tokio::select! {
|
||||||
|
_ = state.done.notified() => Ok(()),
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(config.capture.timeout_secs)) => Err("timed out waiting for the garage request".into()),
|
||||||
|
_ = tokio::signal::ctrl_c() => Err("aborted".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
clear_device_proxy(config);
|
||||||
|
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
|
||||||
|
let _ = adb(config, &["reverse", remove_socket.as_str()]);
|
||||||
|
println!("device proxy settings restored");
|
||||||
|
|
||||||
|
println!("stopping {GAME_PACKAGE}");
|
||||||
|
let _ = adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"));
|
||||||
|
let _ = run_adb(config, &["kill-server"]);
|
||||||
|
println!("adb stopped");
|
||||||
|
if config.device.shutdown_bluestacks {
|
||||||
|
close_bluestacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("capture finished, run `cargo run --bin records -- best` to analyze");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(message) => Err(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_setup(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let project_root = std::env::current_dir()?;
|
||||||
|
let (cert_pem, _) = load_or_create_ca(&project_root)?;
|
||||||
|
ensure_device_connected(config)?;
|
||||||
|
let sdk_level = android_sdk_level(config)?;
|
||||||
|
println!("device {} connected, Android SDK {}", config.device.serial, sdk_level);
|
||||||
|
inject_ca(config, &cert_pem, &project_root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_replay(config: &CaptureConfig) -> Result<()> {
|
||||||
|
let project_root = std::env::current_dir()?;
|
||||||
|
let request_path = project_root.join("captures").join("request.json");
|
||||||
|
if !request_path.exists() {
|
||||||
|
return Err("no captured request found, run `capture run` first".into());
|
||||||
|
}
|
||||||
|
let template: CapturedRequest = serde_json::from_str(&std::fs::read_to_string(&request_path)?)?;
|
||||||
|
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
for (name, value) in &template.headers {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
if matches!(
|
||||||
|
lower.as_str(),
|
||||||
|
"host"
|
||||||
|
| "content-length"
|
||||||
|
| "accept-encoding"
|
||||||
|
| "connection"
|
||||||
|
| "proxy-connection"
|
||||||
|
| "keep-alive"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
http::HeaderName::from_bytes(name.as_bytes())?,
|
||||||
|
http::HeaderValue::from_bytes(value.as_bytes())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = template
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let method: http::Method = template.method.parse()?;
|
||||||
|
let timeout = Duration::from_secs(config.capture.replay_timeout_secs);
|
||||||
|
let http = reqwest::Client::builder().timeout(timeout).build()?;
|
||||||
|
|
||||||
|
println!("replaying {} {}", method, template.url);
|
||||||
|
let bytes = fetch_response(&http, &method, &template.url, &headers, &body, timeout).await?;
|
||||||
|
if !contains_marker(&bytes, GARAGE_MARKER) {
|
||||||
|
return Err(
|
||||||
|
"response no longer contains the garage document, the session token probably expired; run `capture run` again".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||||
|
persist_capture(&project_root, &output_path(config, &project_root), &json, Some(&template))?;
|
||||||
|
println!("replay finished successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_clean(config: &CaptureConfig) -> Result<()> {
|
||||||
|
ensure_device_connected(config)?;
|
||||||
|
clear_device_proxy(config);
|
||||||
|
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
|
||||||
|
let _ = adb(config, &["reverse", remove_socket.as_str()]);
|
||||||
|
let filename = android_ca_filename(CA_COMMON_NAME);
|
||||||
|
let _ = adb_shell_root(config, &format!("rm -f /system/etc/security/cacerts/{filename}"));
|
||||||
|
let _ = adb_shell_root(config, "umount /system/etc/security/cacerts");
|
||||||
|
println!("proxy settings cleared and CA removed from the device");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_bluestacks() {
|
||||||
|
let silent = |args: &[&str]| {
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
};
|
||||||
|
silent(&["/IM", "HD-Player.exe"]);
|
||||||
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
|
silent(&["/F", "/IM", "HD-Player.exe"]);
|
||||||
|
println!("BlueStacks closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_usage() {
|
||||||
|
println!(
|
||||||
|
"usage: capture <command>
|
||||||
|
|
||||||
|
commands:
|
||||||
|
setup generate the CA and inject it into BlueStacks
|
||||||
|
run full flow: start BlueStacks if needed, proxy, game launch, garage capture
|
||||||
|
replay re-issue the stored garage request without the emulator
|
||||||
|
clean remove proxy settings and the injected CA
|
||||||
|
|
||||||
|
configuration is read from {CONFIG_FILE} in the working directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub(crate) async fn run() {
|
||||||
|
let command = std::env::args().nth(1).unwrap_or_default();
|
||||||
|
if command.is_empty() {
|
||||||
|
print_usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_root = std::env::current_dir().expect("cannot determine working directory");
|
||||||
|
let config = match load_config(&project_root) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("error reading {CONFIG_FILE}: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match command.as_str() {
|
||||||
|
"setup" => command_setup(&config).await,
|
||||||
|
"run" => command_run(&config).await,
|
||||||
|
"replay" => command_replay(&config).await,
|
||||||
|
"clean" => command_clean(&config).await,
|
||||||
|
_ => {
|
||||||
|
print_usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("error: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marker_detection_finds_nested_json_key() {
|
||||||
|
let body = br#"{"serverId":"x","playerDeck":[{"cardId":"a"}]}"#;
|
||||||
|
assert!(contains_marker(body, GARAGE_MARKER));
|
||||||
|
assert!(!contains_marker(b"{}", GARAGE_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_ca_filename_is_openssl_style_hash() {
|
||||||
|
let filename = android_ca_filename(CA_COMMON_NAME);
|
||||||
|
assert_eq!(filename.len(), 10);
|
||||||
|
assert!(filename.ends_with(".0"));
|
||||||
|
assert!(filename[..8].chars().all(|character| character.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_hosts_are_filtered() {
|
||||||
|
assert!(is_noise_host("ads.google.com"));
|
||||||
|
assert!(is_noise_host("config.firebase.io"));
|
||||||
|
assert!(!is_noise_host("api.hutchgames.com"));
|
||||||
|
assert!(!is_noise_host("topdrives-api.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_round_trips_through_toml() {
|
||||||
|
let config = CaptureConfig::default();
|
||||||
|
let text = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let parsed: CaptureConfig = toml::from_str(&text).unwrap();
|
||||||
|
assert_eq!(parsed.device.serial, config.device.serial);
|
||||||
|
assert_eq!(parsed.paths.output, config.paths.output);
|
||||||
|
assert_eq!(parsed.proxy.port, config.proxy.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
//! All command implementation.
|
//! All command implementation.
|
||||||
//!
|
//!
|
||||||
//! Generates all output files in a single pass: best.csv, very_best.csv,
|
//! Generates all output files in a single pass: best.csv, very_best.csv,
|
||||||
//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, and improve.csv.
|
//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv,
|
||||||
|
//! protect.csv, and storage.csv.
|
||||||
|
|
||||||
use top_drives::AppError;
|
use top_drives::AppError;
|
||||||
|
|
||||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
use crate::commands::{
|
||||||
|
load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams,
|
||||||
|
};
|
||||||
use crate::output;
|
use crate::output;
|
||||||
|
|
||||||
/// Executes the all command: generates all output files in a single analysis pass.
|
/// Executes the all command: generates all output files in a single analysis pass.
|
||||||
|
|
@ -16,24 +19,27 @@ pub async fn run_all(
|
||||||
output_dir: &str,
|
output_dir: &str,
|
||||||
min_count: usize,
|
min_count: usize,
|
||||||
max_wishlist: usize,
|
max_wishlist: usize,
|
||||||
|
trash_params: &TrashParams,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let owned = my_data.owned.clone();
|
||||||
|
let wishlist = my_data.wishlist.clone();
|
||||||
|
let unlocked = my_data.unlocked.clone();
|
||||||
|
let garage_unlocked = my_data.garage_unlocked.clone();
|
||||||
|
let storage = my_data.storage.clone();
|
||||||
|
let garage = GarageInfo::from_my_cars(&my_data);
|
||||||
|
let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
output::write_best_csv(output_dir, &counts)?;
|
output::write_best_csv(output_dir, &counts)?;
|
||||||
output::write_very_best_csv(output_dir, &counts, min_count)?;
|
output::write_very_best_csv(output_dir, &counts, min_count)?;
|
||||||
output::write_trash_csv(output_dir, &counts, &my_data.owned)?;
|
let thresholds =
|
||||||
output::write_details_csv(output_dir, &ctx)?;
|
write_trash_with_rules(output_dir, &counts, &owned, &unlocked, trash_params, &garage)?;
|
||||||
output::write_wishlist_files(
|
output::write_details_csv(output_dir, &ctx, &counts)?;
|
||||||
output_dir,
|
output::write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
|
||||||
&counts,
|
|
||||||
&my_data.owned,
|
|
||||||
&my_data.wishlist,
|
|
||||||
min_count,
|
|
||||||
max_wishlist,
|
|
||||||
)?;
|
|
||||||
output::write_improve_csv(output_dir, &counts)?;
|
output::write_improve_csv(output_dir, &counts)?;
|
||||||
|
output::write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?;
|
||||||
|
output::write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?;
|
||||||
println!(
|
println!(
|
||||||
"Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv"
|
"Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv, protect.csv, storage.csv"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub async fn run_best(
|
||||||
min_count: usize,
|
min_count: usize,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
write_best_csv(output_dir, &counts)?;
|
write_best_csv(output_dir, &counts)?;
|
||||||
write_very_best_csv(output_dir, &counts, min_count)?;
|
write_very_best_csv(output_dir, &counts, min_count)?;
|
||||||
println!("Written: best.csv, very_best.csv");
|
println!("Written: best.csv, very_best.csv");
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ pub async fn run_details(
|
||||||
output_dir: &str,
|
output_dir: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
write_details_csv(output_dir, &ctx)?;
|
write_details_csv(output_dir, &ctx, &counts)?;
|
||||||
println!("Written: details.csv");
|
println!("Written: details.csv");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub async fn run_improve(
|
||||||
output_dir: &str,
|
output_dir: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
let count = write_improve_csv(output_dir, &counts)?;
|
let count = write_improve_csv(output_dir, &counts)?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
println!("No cars to improve — all owned cars are already in final or maxed configs.");
|
println!("No cars to improve — all owned cars are already in final or maxed configs.");
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,60 @@ pub mod best;
|
||||||
pub mod details;
|
pub mod details;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod improve;
|
pub mod improve;
|
||||||
|
pub mod protect;
|
||||||
pub mod track;
|
pub mod track;
|
||||||
pub mod trash;
|
pub mod trash;
|
||||||
pub mod wishlist;
|
pub mod wishlist;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use top_drives::{
|
use top_drives::{
|
||||||
compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData,
|
compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::output::{
|
||||||
|
collect_improve_entries, plan_trash_rules, write_trash_csv, GarageProjection, TrashThresholds,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trash criteria parameters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Default base threshold for the improve-based trash criteria (RQ 10-19).
|
||||||
|
const DEFAULT_TRASH_BASE_COUNT: usize = 4;
|
||||||
|
|
||||||
|
/// Default minimum projected free garage slots for trash threshold escalation.
|
||||||
|
const DEFAULT_GARAGE_MIN_FREE: usize = 20;
|
||||||
|
|
||||||
|
/// CLI parameters for the improve-based trash criteria.
|
||||||
|
#[derive(clap::Args)]
|
||||||
|
pub struct TrashParams {
|
||||||
|
/// Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39
|
||||||
|
/// get half/a quarter of it. 0 disables these criteria (default: 4)
|
||||||
|
#[arg(long, default_value_t = DEFAULT_TRASH_BASE_COUNT)]
|
||||||
|
pub trash_base_count: usize,
|
||||||
|
/// Minimum projected free garage slots; the base count is raised
|
||||||
|
/// automatically when the projection falls short (default: 20)
|
||||||
|
#[arg(long, default_value_t = DEFAULT_GARAGE_MIN_FREE)]
|
||||||
|
pub garage_min_free: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Garage slot capacity and occupancy from the user export.
|
||||||
|
pub struct GarageInfo {
|
||||||
|
/// Total garage slot capacity (0 = unknown).
|
||||||
|
pub slots: usize,
|
||||||
|
/// Currently occupied garage slots.
|
||||||
|
pub slots_used: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GarageInfo {
|
||||||
|
/// Extracts garage slot data from the parsed user export.
|
||||||
|
pub fn from_my_cars(my_data: &MyCarsData) -> Self {
|
||||||
|
GarageInfo { slots: my_data.garage_slots, slots_used: my_data.garage_slots_used }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use crate::parsing::parse_all_times;
|
use crate::parsing::parse_all_times;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -33,8 +77,8 @@ pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
|
||||||
match load_my_cars_data(path) {
|
match load_my_cars_data(path) {
|
||||||
Ok(data) => Ok(data),
|
Ok(data) => Ok(data),
|
||||||
Err(AppError::MissingFile(msg)) => {
|
Err(AppError::MissingFile(msg)) => {
|
||||||
eprintln!("Warning: {msg}");
|
log::warn!("{msg}");
|
||||||
eprintln!(" Analysis will run without ownership data.");
|
log::warn!("Analysis will run without ownership data.");
|
||||||
Ok(MyCarsData::default())
|
Ok(MyCarsData::default())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
|
|
@ -45,23 +89,119 @@ pub fn load_my_cars_or_warn(path: &str) -> Result<MyCarsData, AppError> {
|
||||||
// Analysis orchestration
|
// Analysis orchestration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Common analysis setup: loads user data, parses all timing files, and computes car counts.
|
/// Common analysis setup: parses all timing files and computes car counts.
|
||||||
|
///
|
||||||
|
/// Takes `my_data` by value: `owned` is shared with the parse tasks via `Arc`,
|
||||||
|
/// and `owned_configs`/`copy_count` are moved into [`CarCounts`]. Callers that
|
||||||
|
/// need `owned` or `wishlist` after analysis must retain their own copy before
|
||||||
|
/// calling this function.
|
||||||
///
|
///
|
||||||
/// Returns the analysis context (for stdout commands) and the aggregated car counts
|
/// Returns the analysis context (for stdout commands) and the aggregated car counts
|
||||||
/// (for CSV output commands). The car list is stored inside `CarCounts`.
|
/// (for CSV output commands).
|
||||||
pub async fn run_analysis(
|
pub async fn run_analysis(
|
||||||
my_data: &MyCarsData,
|
my_data: MyCarsData,
|
||||||
cars_db_path: &str,
|
cars_db_path: &str,
|
||||||
times_dir: &str,
|
times_dir: &str,
|
||||||
) -> Result<(AnalysisContext, CarCounts), AppError> {
|
) -> Result<(AnalysisContext, CarCounts), AppError> {
|
||||||
let ctx = AnalysisContext::new();
|
let ctx = AnalysisContext::new();
|
||||||
let owned = Arc::new(my_data.owned.clone());
|
let MyCarsData { owned, owned_configs, copy_count, .. } = my_data;
|
||||||
|
let owned = Arc::new(owned);
|
||||||
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
|
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
|
||||||
let counts = compute_car_counts(
|
let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
|
||||||
&ctx,
|
|
||||||
car_list,
|
|
||||||
my_data.owned_configs.clone(),
|
|
||||||
my_data.copy_count.clone(),
|
|
||||||
);
|
|
||||||
Ok((ctx, counts))
|
Ok((ctx, counts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trash rule planning output
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Plans the effective trash thresholds, escalating the base value against the
|
||||||
|
/// garage-space projection, and prints the plan.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the improve-based criteria are disabled
|
||||||
|
/// (`trash_base_count == 0`).
|
||||||
|
pub fn plan_thresholds(
|
||||||
|
counts: &CarCounts,
|
||||||
|
unlocked: &HashMap<String, usize>,
|
||||||
|
params: &TrashParams,
|
||||||
|
garage: &GarageInfo,
|
||||||
|
) -> Option<TrashThresholds> {
|
||||||
|
if params.trash_base_count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let improve = collect_improve_entries(counts);
|
||||||
|
let (thresholds, projection) = plan_trash_rules(
|
||||||
|
counts,
|
||||||
|
&improve,
|
||||||
|
unlocked,
|
||||||
|
params.trash_base_count,
|
||||||
|
garage.slots,
|
||||||
|
garage.slots_used,
|
||||||
|
params.garage_min_free,
|
||||||
|
);
|
||||||
|
print_trash_plan(&thresholds, &projection, params.garage_min_free);
|
||||||
|
Some(thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plans the effective trash thresholds (printing the garage-space projection)
|
||||||
|
/// and writes trash.csv, returning the thresholds applied for other outputs
|
||||||
|
/// (protect.csv, storage.csv).
|
||||||
|
///
|
||||||
|
/// The caller is responsible for printing the "Written: ..." line so compound
|
||||||
|
/// commands like `all` can fold it into their summary.
|
||||||
|
pub fn write_trash_with_rules(
|
||||||
|
output_dir: &str,
|
||||||
|
counts: &CarCounts,
|
||||||
|
owned: &HashSet<String>,
|
||||||
|
unlocked: &HashMap<String, usize>,
|
||||||
|
params: &TrashParams,
|
||||||
|
garage: &GarageInfo,
|
||||||
|
) -> Result<Option<TrashThresholds>, AppError> {
|
||||||
|
let thresholds = plan_thresholds(counts, unlocked, params, garage);
|
||||||
|
let improve = collect_improve_entries(counts);
|
||||||
|
write_trash_csv(output_dir, counts, owned, thresholds.as_ref(), &improve)?;
|
||||||
|
Ok(thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints the effective trash thresholds and the garage-space projection.
|
||||||
|
pub fn print_trash_plan(
|
||||||
|
thresholds: &TrashThresholds,
|
||||||
|
projection: &GarageProjection,
|
||||||
|
min_free: usize,
|
||||||
|
) {
|
||||||
|
let base_note = if projection.base_count != projection.configured_base_count {
|
||||||
|
format!(
|
||||||
|
" (base raised from {} to {})",
|
||||||
|
projection.configured_base_count, projection.base_count
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"Trash thresholds: RQ 10-19: count <= {}, RQ 20-29: count <= {}, RQ 30-39: count <= {}{}",
|
||||||
|
thresholds.rq10_19, thresholds.rq20_29, thresholds.rq30_39, base_note
|
||||||
|
);
|
||||||
|
match projection.projected_free {
|
||||||
|
Some(free) => {
|
||||||
|
println!(
|
||||||
|
"Garage: {}/{} slots used ({} free now), {} unlocked to delete, {} unlocked to lock, {} new trash candidates -> {} free projected (target: {})",
|
||||||
|
projection.slots_used,
|
||||||
|
projection.garage_slots,
|
||||||
|
projection.free_now().unwrap_or(0),
|
||||||
|
projection.unlocked_freed,
|
||||||
|
projection.unlocked_protected,
|
||||||
|
projection.trash_candidates,
|
||||||
|
free,
|
||||||
|
min_free
|
||||||
|
);
|
||||||
|
if free < min_free {
|
||||||
|
log::warn!(
|
||||||
|
"Projected free garage slots ({free}) stay below the target ({min_free}) — no further cars qualify for the trash criteria"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("Garage: no slot data in export — thresholds applied without space projection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
40
src/commands/protect.rs
Normal file
40
src/commands/protect.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
//! Protect command implementation.
|
||||||
|
//!
|
||||||
|
//! Generates protect.csv (unlocked garage cars worth locking more copies of)
|
||||||
|
//! and storage.csv (a keep/trash recommendation for cars in the storage),
|
||||||
|
//! both applying the same dynamic trash criteria as the trash command —
|
||||||
|
//! including the garage-space threshold escalation.
|
||||||
|
|
||||||
|
use top_drives::AppError;
|
||||||
|
|
||||||
|
use crate::commands::{
|
||||||
|
load_my_cars_or_warn, plan_thresholds, run_analysis, GarageInfo, TrashParams,
|
||||||
|
};
|
||||||
|
use crate::output::{write_protect_csv, write_storage_csv};
|
||||||
|
|
||||||
|
/// Executes the protect command: generates protect.csv and storage.csv.
|
||||||
|
///
|
||||||
|
/// protect.csv covers unlocked cars in the **garage** (`state != 0`,
|
||||||
|
/// `locked == false`); storage.csv covers cars in the **storage** (`state == 0`).
|
||||||
|
pub async fn run_protect(
|
||||||
|
my_cars_path: &str,
|
||||||
|
cars_db_path: &str,
|
||||||
|
times_dir: &str,
|
||||||
|
output_dir: &str,
|
||||||
|
params: &TrashParams,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
|
let unlocked = my_data.unlocked.clone();
|
||||||
|
let garage_unlocked = my_data.garage_unlocked.clone();
|
||||||
|
let storage = my_data.storage.clone();
|
||||||
|
let garage = GarageInfo::from_my_cars(&my_data);
|
||||||
|
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
|
let thresholds = plan_thresholds(&counts, &unlocked, params, &garage);
|
||||||
|
|
||||||
|
let protected = write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?;
|
||||||
|
let (keep, trash) = write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?;
|
||||||
|
println!(
|
||||||
|
"Written: protect.csv ({protected} garage cars to lock), storage.csv ({keep} keep, {trash} trash)"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -9,19 +9,16 @@ use crate::output::print_best_car;
|
||||||
|
|
||||||
/// Executes the track command: shows best car for a specific track/condition/RQ.
|
/// Executes the track command: shows best car for a specific track/condition/RQ.
|
||||||
///
|
///
|
||||||
/// Note: `_output_dir` is unused because the track command prints to stdout
|
/// Prints to stdout rather than writing a CSV file, so no output directory is needed.
|
||||||
/// rather than writing a CSV file. It exists for API consistency with other
|
|
||||||
/// command signatures so the caller doesn't need per-command branching.
|
|
||||||
pub async fn run_track(
|
pub async fn run_track(
|
||||||
my_cars_path: &str,
|
my_cars_path: &str,
|
||||||
cars_db_path: &str,
|
cars_db_path: &str,
|
||||||
times_dir: &str,
|
times_dir: &str,
|
||||||
_output_dir: &str,
|
|
||||||
track: &str,
|
track: &str,
|
||||||
condition: &str,
|
condition: &str,
|
||||||
rq: u8,
|
rq: u8,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (ctx, _counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let (ctx, _counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
print_best_car(&ctx, track, condition, rq)
|
print_best_car(&ctx, track, condition, rq)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
//! Trash command implementation.
|
//! Trash command implementation.
|
||||||
//!
|
//!
|
||||||
//! Generates trash.csv — owned cars that are not best on any track in their
|
//! Generates trash.csv — owned cars that are not best on any track in their
|
||||||
//! current configuration (RQ < 80, not prize). Includes cars whose actual
|
//! current configuration (RQ < 80, not prize), plus low-value upgrade fodder
|
||||||
//! final/maxed build is not best, even if other configs of the same car are.
|
//! selected by RQ-band track-count thresholds. The base threshold is escalated
|
||||||
|
//! automatically when the projected free garage slots would fall short of a
|
||||||
|
//! configurable reserve.
|
||||||
|
|
||||||
use top_drives::AppError;
|
use top_drives::AppError;
|
||||||
|
|
||||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
use crate::commands::{
|
||||||
use crate::output::write_trash_csv;
|
load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams,
|
||||||
|
};
|
||||||
|
|
||||||
/// Executes the trash command: generates trash.csv.
|
/// Executes the trash command: generates trash.csv.
|
||||||
pub async fn run_trash(
|
pub async fn run_trash(
|
||||||
|
|
@ -15,10 +18,14 @@ pub async fn run_trash(
|
||||||
cars_db_path: &str,
|
cars_db_path: &str,
|
||||||
times_dir: &str,
|
times_dir: &str,
|
||||||
output_dir: &str,
|
output_dir: &str,
|
||||||
|
params: &TrashParams,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let owned = my_data.owned.clone();
|
||||||
write_trash_csv(output_dir, &counts, &my_data.owned)?;
|
let unlocked = my_data.unlocked.clone();
|
||||||
|
let garage = GarageInfo::from_my_cars(&my_data);
|
||||||
|
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
|
write_trash_with_rules(output_dir, &counts, &owned, &unlocked, params, &garage)?;
|
||||||
println!("Written: trash.csv");
|
println!("Written: trash.csv");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,10 @@ pub async fn run_wishlist(
|
||||||
max_wishlist: usize,
|
max_wishlist: usize,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (_ctx, counts) = run_analysis(&my_data, cars_db_path, times_dir).await?;
|
let owned = my_data.owned.clone();
|
||||||
write_wishlist_files(
|
let wishlist = my_data.wishlist.clone();
|
||||||
output_dir,
|
let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
&counts,
|
write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
|
||||||
&my_data.owned,
|
|
||||||
&my_data.wishlist,
|
|
||||||
min_count,
|
|
||||||
max_wishlist,
|
|
||||||
)?;
|
|
||||||
println!("Written: wishlist.csv, add.csv, remove.csv");
|
println!("Written: wishlist.csv, add.csv, remove.csv");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
201
src/context.rs
201
src/context.rs
|
|
@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
|
||||||
use crate::types::{BestCarRecord, Car, TripletKey};
|
use crate::types::{is_final_build, BestCarRecord, Car, TripletKey};
|
||||||
|
|
||||||
/// Holds the mutable analysis state for a records analysis run.
|
/// Holds the mutable analysis state for a records analysis run.
|
||||||
///
|
///
|
||||||
|
|
@ -50,9 +50,9 @@ pub struct CarCounts {
|
||||||
pub guid_to_rid: HashMap<String, HashSet<(String, String)>>,
|
pub guid_to_rid: HashMap<String, HashSet<(String, String)>>,
|
||||||
/// Map from guid → Car (for O(1) lookups of car metadata by GUID).
|
/// Map from guid → Car (for O(1) lookups of car metadata by GUID).
|
||||||
/// Built once in `compute_car_counts()` and reused by trash/wishlist output.
|
/// Built once in `compute_car_counts()` and reused by trash/wishlist output.
|
||||||
|
/// Sole owner of the car database — iterate `car_map.values()` where a
|
||||||
|
/// car list was previously used.
|
||||||
pub car_map: HashMap<String, Car>,
|
pub car_map: HashMap<String, Car>,
|
||||||
/// Full car list from the database (kept for any callers that need iteration).
|
|
||||||
pub car_list: Vec<Car>,
|
|
||||||
/// Actual tune configs for each owned car (guid → list of configs from all copies).
|
/// Actual tune configs for each owned car (guid → list of configs from all copies).
|
||||||
/// Used by the improve command to determine which cars are not yet at a final build.
|
/// Used by the improve command to determine which cars are not yet at a final build.
|
||||||
pub owned_configs: HashMap<String, Vec<String>>,
|
pub owned_configs: HashMap<String, Vec<String>>,
|
||||||
|
|
@ -62,6 +62,55 @@ pub struct CarCounts {
|
||||||
pub copy_count: HashMap<String, usize>,
|
pub copy_count: HashMap<String, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves same-car tie duplicates within one track's best-car list.
|
||||||
|
///
|
||||||
|
/// All records in a track list are within the time tolerance of each other,
|
||||||
|
/// so multiple entries of the **same car** (different tune configs) are
|
||||||
|
/// tie-equivalent: either tune achieves the same time, and counting both
|
||||||
|
/// would double-claim the track per config and surface pointless
|
||||||
|
/// recommendations (e.g. wishlisting a second copy tuned 332 when the owned
|
||||||
|
/// 323 ties it). Exactly one config per car is kept:
|
||||||
|
///
|
||||||
|
/// - the player's owned final-build config, if one of the tied configs is
|
||||||
|
/// owned (so downstream ownership checks match what the player has), else
|
||||||
|
/// - the first record in list order (deterministic).
|
||||||
|
///
|
||||||
|
/// Records of other cars are passed through unchanged, preserving order.
|
||||||
|
pub fn resolve_tied_configs<'a>(
|
||||||
|
records: &'a [BestCarRecord],
|
||||||
|
owned_final: &HashMap<&str, HashSet<&str>>,
|
||||||
|
) -> Vec<&'a BestCarRecord> {
|
||||||
|
let is_owned_final = |record: &BestCarRecord| -> bool {
|
||||||
|
owned_final
|
||||||
|
.get(record.guid.as_str())
|
||||||
|
.is_some_and(|configs| configs.contains(record.normalized_config.as_str()))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut chosen: HashMap<&str, usize> = HashMap::with_capacity(records.len());
|
||||||
|
for (index, record) in records.iter().enumerate() {
|
||||||
|
match chosen.get(record.car_rid.as_str()) {
|
||||||
|
Some(¤t) => {
|
||||||
|
// Replace only when the current pick is not owned-final and
|
||||||
|
// this record is — first-seen order wins otherwise.
|
||||||
|
let current_is_owned = is_owned_final(&records[current]);
|
||||||
|
if !current_is_owned && is_owned_final(record) {
|
||||||
|
chosen.insert(record.car_rid.as_str(), index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
chosen.insert(record.car_rid.as_str(), index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(index, record)| chosen.get(record.car_rid.as_str()) == Some(index))
|
||||||
|
.map(|(_, record)| record)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Computes per-car win counts from the analysis context.
|
/// Computes per-car win counts from the analysis context.
|
||||||
///
|
///
|
||||||
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
||||||
|
|
@ -69,18 +118,30 @@ pub struct CarCounts {
|
||||||
/// `HashMap` inserts.
|
/// `HashMap` inserts.
|
||||||
///
|
///
|
||||||
/// Groups by `(car_rid, normalized_config)` so that the same car with different
|
/// Groups by `(car_rid, normalized_config)` so that the same car with different
|
||||||
/// final builds is counted as separate entries.
|
/// final builds is counted as separate entries. Same-car ties on a track are
|
||||||
|
/// attributed to a single config (see [`resolve_tied_configs`]).
|
||||||
pub fn compute_car_counts(
|
pub fn compute_car_counts(
|
||||||
ctx: &AnalysisContext,
|
ctx: &AnalysisContext,
|
||||||
car_list: Vec<Car>,
|
car_list: Vec<Car>,
|
||||||
owned_configs: HashMap<String, Vec<String>>,
|
owned_configs: HashMap<String, Vec<String>>,
|
||||||
copy_count: HashMap<String, usize>,
|
copy_count: HashMap<String, usize>,
|
||||||
) -> CarCounts {
|
) -> CarCounts {
|
||||||
|
// guid → owned final-build configs, borrowed views for tie attribution.
|
||||||
|
let owned_final: HashMap<&str, HashSet<&str>> = owned_configs
|
||||||
|
.iter()
|
||||||
|
.map(|(guid, configs)| {
|
||||||
|
(
|
||||||
|
guid.as_str(),
|
||||||
|
configs.iter().filter(|c| is_final_build(c)).map(|c| c.as_str()).collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new();
|
let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new();
|
||||||
let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new();
|
let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new();
|
||||||
|
|
||||||
for entry in ctx.best_cars.iter() {
|
for entry in ctx.best_cars.iter() {
|
||||||
for best_car in entry.value() {
|
for best_car in resolve_tied_configs(entry.value(), &owned_final) {
|
||||||
let key = (best_car.car_rid.clone(), best_car.normalized_config.clone());
|
let key = (best_car.car_rid.clone(), best_car.normalized_config.clone());
|
||||||
let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize));
|
let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize));
|
||||||
rid_entry.1 += 1;
|
rid_entry.1 += 1;
|
||||||
|
|
@ -94,10 +155,10 @@ pub fn compute_car_counts(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build guid → Car lookup once, reused by trash/wishlist output functions.
|
// Build guid → Car lookup once, reused by trash/wishlist output functions.
|
||||||
let car_map: HashMap<String, Car> =
|
// Consumes car_list so each Car is stored exactly once.
|
||||||
car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect();
|
let car_map: HashMap<String, Car> = car_list.into_iter().map(|c| (c.guid.clone(), c)).collect();
|
||||||
|
|
||||||
CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs, copy_count }
|
CarCounts { by_rid, guid_to_rid, car_map, owned_configs, copy_count }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -109,6 +170,128 @@ mod tests {
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tie_record(rid: &str, config: &str, guid: &str, time: f64) -> BestCarRecord {
|
||||||
|
BestCarRecord {
|
||||||
|
car_rid: rid.to_string(),
|
||||||
|
brand: "TestBrand".to_string(),
|
||||||
|
only_name: "TestModel".to_string(),
|
||||||
|
year: 2020,
|
||||||
|
rq: 85,
|
||||||
|
config: config.to_string(),
|
||||||
|
normalized_config: config.to_string(),
|
||||||
|
time,
|
||||||
|
owned: false,
|
||||||
|
prize: false,
|
||||||
|
guid: guid.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned_final_map<'a>(
|
||||||
|
guid: &'a str,
|
||||||
|
configs: &[&'a str],
|
||||||
|
) -> HashMap<&'a str, HashSet<&'a str>> {
|
||||||
|
HashMap::from([(guid, configs.iter().copied().collect::<HashSet<&'a str>>())])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_tied_configs_keeps_single_config_per_car() {
|
||||||
|
// Mercedes 280 SL case: 323 and 332 tie; unowned → first kept.
|
||||||
|
let records = vec![
|
||||||
|
tie_record("Car_A", "323", "guid_a", 61.31),
|
||||||
|
tie_record("Car_B", "332", "guid_b", 61.31),
|
||||||
|
tie_record("Car_A", "332", "guid_a", 61.31),
|
||||||
|
];
|
||||||
|
|
||||||
|
let resolved = resolve_tied_configs(&records, &HashMap::new());
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 2, "one config per car plus the other car");
|
||||||
|
assert!(resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "323"));
|
||||||
|
assert!(resolved.iter().any(|r| r.car_rid == "Car_B"));
|
||||||
|
assert!(!resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "332"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_tied_configs_prefers_owned_final_config() {
|
||||||
|
// Player owns 332 as a final build → the tie is attributed to 332
|
||||||
|
// even though 323 comes first.
|
||||||
|
let records = vec![
|
||||||
|
tie_record("Car_A", "323", "guid_a", 61.31),
|
||||||
|
tie_record("Car_A", "332", "guid_a", 61.31),
|
||||||
|
];
|
||||||
|
|
||||||
|
let owned_final = owned_final_map("guid_a", &["332"]);
|
||||||
|
let resolved = resolve_tied_configs(&records, &owned_final);
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].normalized_config, "332");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_tied_configs_ignores_owned_non_final_config() {
|
||||||
|
// An unfinished owned build does not absorb the tie.
|
||||||
|
let records = vec![
|
||||||
|
tie_record("Car_A", "323", "guid_a", 61.31),
|
||||||
|
tie_record("Car_A", "332", "guid_a", 61.31),
|
||||||
|
];
|
||||||
|
|
||||||
|
// "332" owned but NOT final is not in owned_final — first config kept.
|
||||||
|
let owned_final: HashMap<&str, HashSet<&str>> = HashMap::new();
|
||||||
|
let resolved = resolve_tied_configs(&records, &owned_final);
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].normalized_config, "323");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_tied_configs_keeps_first_owned_when_several_owned() {
|
||||||
|
// Both configs owned as final builds → the first owned one wins.
|
||||||
|
let records = vec![
|
||||||
|
tie_record("Car_A", "323", "guid_a", 61.31),
|
||||||
|
tie_record("Car_A", "332", "guid_a", 61.31),
|
||||||
|
];
|
||||||
|
|
||||||
|
let owned_final = owned_final_map("guid_a", &["323", "332"]);
|
||||||
|
let resolved = resolve_tied_configs(&records, &owned_final);
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].normalized_config, "323");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_car_counts_attributes_tie_to_owned_config() {
|
||||||
|
// End-to-end: the tie track counts for the owned config only.
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
ctx.best_cars.insert(
|
||||||
|
("Ocean Highway".to_string(), 21, "Wet".to_string()),
|
||||||
|
vec![
|
||||||
|
tie_record("Car_A", "323", "guid_a", 61.31),
|
||||||
|
tie_record("Car_A", "332", "guid_a", 61.31),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let car_list = vec![Car {
|
||||||
|
rid: "Car_A".to_string(),
|
||||||
|
brand: "TestBrand".to_string(),
|
||||||
|
only_name: "TestModel".to_string(),
|
||||||
|
year: 2020,
|
||||||
|
rq: 21,
|
||||||
|
prize: false,
|
||||||
|
guid: "guid_a".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let mut owned_configs = HashMap::new();
|
||||||
|
owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
|
||||||
|
|
||||||
|
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
|
||||||
|
|
||||||
|
let count_323 =
|
||||||
|
counts.by_rid.get(&("Car_A".to_string(), "323".to_string())).map(|(_, c)| *c);
|
||||||
|
let count_332 =
|
||||||
|
counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).map(|(_, c)| *c);
|
||||||
|
assert_eq!(count_323, Some(1), "tie attributed to the owned config");
|
||||||
|
assert_eq!(count_332, None, "tie-equivalent config is not counted");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_compute_car_counts_basic() {
|
fn test_compute_car_counts_basic() {
|
||||||
let ctx = AnalysisContext::new();
|
let ctx = AnalysisContext::new();
|
||||||
|
|
@ -158,7 +341,7 @@ mod tests {
|
||||||
|
|
||||||
// car_map should have the car
|
// car_map should have the car
|
||||||
assert!(counts.car_map.contains_key("guid_test"));
|
assert!(counts.car_map.contains_key("guid_test"));
|
||||||
assert_eq!(counts.car_list.len(), 1);
|
assert_eq!(counts.car_map.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
50
src/http.rs
50
src/http.rs
|
|
@ -9,6 +9,11 @@ use reqwest::get;
|
||||||
|
|
||||||
use crate::types::AppError;
|
use crate::types::AppError;
|
||||||
|
|
||||||
|
/// Linear backoff between retry attempts: 500ms, 1000ms, 1500ms, ...
|
||||||
|
fn retry_delay(attempt: usize) -> std::time::Duration {
|
||||||
|
std::time::Duration::from_millis(500 * (attempt as u64 + 1))
|
||||||
|
}
|
||||||
|
|
||||||
/// Downloads a file from a URL and saves it locally.
|
/// Downloads a file from a URL and saves it locally.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(())` on success, or an error describing what went wrong.
|
/// Returns `Ok(())` on success, or an error describing what went wrong.
|
||||||
|
|
@ -16,10 +21,13 @@ use crate::types::AppError;
|
||||||
/// Writes to a temporary file first, then renames on success, so a failed
|
/// Writes to a temporary file first, then renames on success, so a failed
|
||||||
/// download never deletes the existing file.
|
/// download never deletes the existing file.
|
||||||
///
|
///
|
||||||
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors,
|
/// Retries up to `MAX_FETCH_RETRIES` times on transient failures (server errors
|
||||||
/// connection errors, or empty responses). Permanent HTTP errors (4xx) fail immediately.
|
/// or connection errors). Permanent conditions fail immediately: client HTTP
|
||||||
|
/// errors (4xx) and empty bodies ([`AppError::EmptyResponse`] — the records API
|
||||||
|
/// reports "no data submitted yet" as a 200 with an empty body, which no amount
|
||||||
|
/// of retrying can change).
|
||||||
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> {
|
pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppError> {
|
||||||
/// Maximum number of retries when the server returns an empty response.
|
/// Maximum number of retries on transient failures (5xx, connection errors).
|
||||||
const MAX_FETCH_RETRIES: usize = 3;
|
const MAX_FETCH_RETRIES: usize = 3;
|
||||||
|
|
||||||
// Parse the URL once so malformed URLs are caught before any network I/O.
|
// Parse the URL once so malformed URLs are caught before any network I/O.
|
||||||
|
|
@ -39,15 +47,12 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
// Only retry on server errors (5xx); client errors (4xx) are permanent.
|
// Only retry on server errors (5xx); client errors (4xx) are permanent.
|
||||||
if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
|
if status.is_server_error() && attempt < MAX_FETCH_RETRIES {
|
||||||
eprintln!(
|
log::warn!(
|
||||||
"Warning: HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
"HTTP {} from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
||||||
status,
|
status,
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
);
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(
|
tokio::time::sleep(retry_delay(attempt)).await;
|
||||||
500 * (attempt + 1) as u64,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -56,21 +61,9 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
|
||||||
match response.text().await {
|
match response.text().await {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
if attempt < MAX_FETCH_RETRIES {
|
// "Known resource, no data yet" — deterministic, so
|
||||||
eprintln!(
|
// fail immediately instead of burning retries.
|
||||||
"Warning: empty response from {file_url} (attempt {}/{MAX_FETCH_RETRIES}), retrying...",
|
return Err(AppError::EmptyResponse { url: file_url.to_string() });
|
||||||
attempt + 1,
|
|
||||||
);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(
|
|
||||||
500 * (attempt + 1) as u64,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
attempt += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(AppError::Network(format!(
|
|
||||||
"Empty response from {file_url} after {MAX_FETCH_RETRIES} retries"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
fs::write(&tmp_path, &content).map_err(AppError::Io)?;
|
fs::write(&tmp_path, &content).map_err(AppError::Io)?;
|
||||||
return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io);
|
return fs::rename(&tmp_path, local_file_path).map_err(AppError::Io);
|
||||||
|
|
@ -84,14 +77,11 @@ pub async fn get_file(file_url: &str, local_file_path: &str) -> Result<(), AppEr
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if attempt < MAX_FETCH_RETRIES {
|
if attempt < MAX_FETCH_RETRIES {
|
||||||
eprintln!(
|
log::warn!(
|
||||||
"Warning: failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
|
"failed to fetch {file_url} (attempt {}/{MAX_FETCH_RETRIES}): {error}",
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
);
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(
|
tokio::time::sleep(retry_delay(attempt)).await;
|
||||||
500 * (attempt + 1) as u64,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ pub mod test_utils;
|
||||||
|
|
||||||
// Re-export all public types and functions for backward compatibility.
|
// Re-export all public types and functions for backward compatibility.
|
||||||
// External code can use `top_drives::Car` etc. without knowing the internal module layout.
|
// External code can use `top_drives::Car` etc. without knowing the internal module layout.
|
||||||
pub use context::{compute_car_counts, AnalysisContext, CarCounts};
|
pub use context::{compute_car_counts, resolve_tied_configs, AnalysisContext, CarCounts};
|
||||||
pub use csv_util::{compare_cars, escape_csv_field};
|
pub use csv_util::{compare_cars, escape_csv_field};
|
||||||
pub use http::get_file;
|
pub use http::get_file;
|
||||||
pub use translate::{translate_track_name, translate_track_type};
|
pub use translate::{translate_track_name, translate_track_type};
|
||||||
|
|
|
||||||
1326
src/output/mod.rs
1326
src/output/mod.rs
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
|
|
@ -89,13 +90,20 @@ pub fn parse_car_times(
|
||||||
// Read file content
|
// Read file content
|
||||||
let file_content = match fs::read_to_string(&local_file_path) {
|
let file_content = match fs::read_to_string(&local_file_path) {
|
||||||
Ok(content) => content,
|
Ok(content) => content,
|
||||||
|
// File not found is expected for most cars (no timing data recorded) — silent skip.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// File not found is expected for some cars — treat as non-fatal
|
log::warn!("could not read {local_file_path}: {e}");
|
||||||
eprintln!("Warning: could not read {local_file_path}: {e}");
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// An empty file means "no timing data" (the API reports this as an empty
|
||||||
|
// body) — same as a missing file, not a parse error.
|
||||||
|
if file_content.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Parse JSON data
|
// Parse JSON data
|
||||||
let json_data: Value = match serde_json::from_str(&file_content) {
|
let json_data: Value = match serde_json::from_str(&file_content) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
|
|
@ -106,7 +114,7 @@ pub fn parse_car_times(
|
||||||
|
|
||||||
// Process each configuration variant (111, 233, 323, etc.)
|
// Process each configuration variant (111, 233, 323, etc.)
|
||||||
if let Some(configurations) = json_data["data"].as_object() {
|
if let Some(configurations) = json_data["data"].as_object() {
|
||||||
for (raw_config, entry) in configurations {
|
for (raw_config, config_data) in configurations {
|
||||||
// Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111")
|
// Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111")
|
||||||
// at read time. Downstream code only ever sees bare digit strings.
|
// at read time. Downstream code only ever sees bare digit strings.
|
||||||
// Generic approach: find the last '_' and take what follows it,
|
// Generic approach: find the last '_' and take what follows it,
|
||||||
|
|
@ -121,13 +129,20 @@ pub fn parse_car_times(
|
||||||
.map(|(_, suffix)| suffix.to_string())
|
.map(|(_, suffix)| suffix.to_string())
|
||||||
.unwrap_or_else(|| raw_config.clone());
|
.unwrap_or_else(|| raw_config.clone());
|
||||||
// Process timing data for each track
|
// Process timing data for each track
|
||||||
if let Some(times) = entry["times"].as_object() {
|
if let Some(times) = config_data["times"].as_object() {
|
||||||
for (track_id, details) in times {
|
for (track_id, details) in times {
|
||||||
// Parse track ID to separate base track name from surface condition.
|
// Parse track ID to separate base track name from surface condition.
|
||||||
// Use rsplit_once to split on the LAST "_a" so track names that
|
// Use rsplit_once to split on the LAST "_a" so track names that
|
||||||
// contain "_a" (e.g. "airplaneHangars") are handled correctly.
|
// contain "_a" (e.g. "airplaneHangars") are handled correctly.
|
||||||
if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") {
|
if let Some((cleaned_track_id, track_type)) = track_id.rsplit_once("_a") {
|
||||||
let mut time = details["t"].as_f64().unwrap_or(0.0);
|
let Some(mut time) = details["t"].as_f64() else {
|
||||||
|
log::warn!(
|
||||||
|
"skipping {}/{}: missing or non-numeric time field",
|
||||||
|
car.rid,
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let down_votes = details["down"].as_array();
|
let down_votes = details["down"].as_array();
|
||||||
let no_down_votes = match down_votes {
|
let no_down_votes = match down_votes {
|
||||||
|
|
@ -302,12 +317,12 @@ pub async fn parse_all_times(
|
||||||
match result {
|
match result {
|
||||||
// Outer JoinError from tokio::spawn
|
// Outer JoinError from tokio::spawn
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Warning: parsing task failed: {e}");
|
log::warn!("parsing task failed: {e}");
|
||||||
parse_errors += 1;
|
parse_errors += 1;
|
||||||
}
|
}
|
||||||
// parse_car_times or semaphore returned an error
|
// parse_car_times or semaphore returned an error
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("Warning: {e}");
|
log::warn!("{e}");
|
||||||
parse_errors += 1;
|
parse_errors += 1;
|
||||||
}
|
}
|
||||||
// parse_car_times succeeded
|
// parse_car_times succeeded
|
||||||
|
|
@ -315,7 +330,7 @@ pub async fn parse_all_times(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if parse_errors > 0 {
|
if parse_errors > 0 {
|
||||||
eprintln!("Warning: {parse_errors} car(s) had parse errors during analysis");
|
log::warn!("{parse_errors} car(s) had parse errors during analysis");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If every car failed to parse, the data is unusable — propagate the error.
|
// If every car failed to parse, the data is unusable — propagate the error.
|
||||||
|
|
@ -328,6 +343,15 @@ pub async fn parse_all_times(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing timing files are silent (expected for most cars), so a wrong or
|
||||||
|
// empty --times-dir would otherwise produce empty CSVs without any hint.
|
||||||
|
if ctx.best_cars.is_empty() && !car_list.is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"no timing data found in {times_dir} for {} car(s) — all output CSVs will be empty",
|
||||||
|
car_list.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(car_list)
|
Ok(car_list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,38 +364,56 @@ const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/";
|
||||||
|
|
||||||
/// Fetches timing data files for all cars using controlled concurrency.
|
/// Fetches timing data files for all cars using controlled concurrency.
|
||||||
///
|
///
|
||||||
|
/// Displays a single-line progress counter (completed/total cars) that stays
|
||||||
|
/// on the bottom line while downloads run. The counter tracks completions,
|
||||||
|
/// not cars seen, since downloads run concurrently under the semaphore.
|
||||||
|
///
|
||||||
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
|
/// Returns the first error encountered, or `Ok(())` if all downloads succeeded.
|
||||||
pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> {
|
||||||
|
let car_list = get_car_list(cars_db_path)?;
|
||||||
|
|
||||||
|
let progress = ProgressBar::new(car_list.len() as u64);
|
||||||
|
progress.set_style(
|
||||||
|
ProgressStyle::with_template("{msg} {pos}/{len}")
|
||||||
|
.expect("static progress template is valid"),
|
||||||
|
);
|
||||||
|
progress.set_message("Downloading track times for car");
|
||||||
|
|
||||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
|
let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT));
|
||||||
let mut handles = vec![];
|
let mut handles = vec![];
|
||||||
|
|
||||||
for car in get_car_list(cars_db_path)? {
|
for car in car_list {
|
||||||
let permit = semaphore
|
let permit = semaphore
|
||||||
.clone()
|
.clone()
|
||||||
.acquire_owned()
|
.acquire_owned()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
|
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
|
||||||
let times_dir = times_dir.to_string();
|
let times_dir = times_dir.to_string();
|
||||||
|
let progress = progress.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = permit; // held until end of block
|
let _permit = permit; // held until end of block
|
||||||
fetch_car(&car.rid, ×_dir).await
|
let result = fetch_car(&car.rid, ×_dir).await;
|
||||||
|
progress.inc(1);
|
||||||
|
result
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = join_all(handles).await;
|
let results = join_all(handles).await;
|
||||||
|
// Clear the counter before error logging so warnings don't garble the line.
|
||||||
|
progress.finish_and_clear();
|
||||||
let mut first_error: Option<AppError> = None;
|
let mut first_error: Option<AppError> = None;
|
||||||
for result in results {
|
for result in results {
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("{e}");
|
log::warn!("{e}");
|
||||||
if first_error.is_none() {
|
if first_error.is_none() {
|
||||||
first_error = Some(e);
|
first_error = Some(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = AppError::Network(format!("Task join error: {e}"));
|
let msg = AppError::Network(format!("Task join error: {e}"));
|
||||||
eprintln!("{msg}");
|
log::warn!("{msg}");
|
||||||
if first_error.is_none() {
|
if first_error.is_none() {
|
||||||
first_error = Some(msg);
|
first_error = Some(msg);
|
||||||
}
|
}
|
||||||
|
|
@ -382,12 +424,23 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads timing data for a single car from the remote API.
|
/// Downloads timing data for a single car from the remote API.
|
||||||
|
///
|
||||||
|
/// An empty response means the records site knows the car but no times have
|
||||||
|
/// been submitted yet (typical for freshly released prize cars) — treated as
|
||||||
|
/// a skip, not a failure, so the overall fetch is not aborted.
|
||||||
async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> {
|
async fn fetch_car(rid: &str, times_dir: &str) -> Result<(), AppError> {
|
||||||
let file_name = rid.to_owned() + ".json";
|
let file_name = rid.to_owned() + ".json";
|
||||||
let url = format!("{CAR_TIMING_API_BASE}{rid}");
|
let url = format!("{CAR_TIMING_API_BASE}{rid}");
|
||||||
let local_file_path = format!("{times_dir}/{file_name}");
|
let local_file_path = format!("{times_dir}/{file_name}");
|
||||||
println!("Fetching {url}.");
|
log::info!("Fetching {url}.");
|
||||||
top_drives::get_file(&url, &local_file_path).await
|
match top_drives::get_file(&url, &local_file_path).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(AppError::EmptyResponse { .. }) => {
|
||||||
|
log::info!("no records on the site yet for {rid} — skipped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -494,6 +547,44 @@ mod tests {
|
||||||
assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list");
|
assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_best_car_same_car_different_config_slower_not_added() {
|
||||||
|
// A slower config of the same car is simply discarded (existing rule).
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
|
||||||
|
let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a");
|
||||||
|
record_323.normalized_config = "323".to_string();
|
||||||
|
|
||||||
|
let mut record_332 = make_record("Car_A", 85, 100.4, "guid_a");
|
||||||
|
record_332.normalized_config = "332".to_string();
|
||||||
|
|
||||||
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323);
|
||||||
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332);
|
||||||
|
|
||||||
|
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].normalized_config, "323");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_best_car_same_car_different_config_faster_replaces() {
|
||||||
|
// A strictly faster config of the same car replaces the entry.
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
|
||||||
|
let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a");
|
||||||
|
record_323.normalized_config = "323".to_string();
|
||||||
|
|
||||||
|
let mut record_332 = make_record("Car_A", 85, 99.0, "guid_a");
|
||||||
|
record_332.normalized_config = "332".to_string();
|
||||||
|
|
||||||
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323);
|
||||||
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332);
|
||||||
|
|
||||||
|
let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].normalized_config, "332", "Faster config replaces the slower one");
|
||||||
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
// parse_car_times tests
|
// parse_car_times tests
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
@ -536,6 +627,55 @@ mod tests {
|
||||||
assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string())));
|
assert!(ctx.best_cars.contains_key(&("Monaco Streets".to_string(), 85, "Wet".to_string())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_car_times_skips_missing_or_non_numeric_time() {
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
let car = Car {
|
||||||
|
rid: "TestCar_2020".to_string(),
|
||||||
|
brand: "TestBrand".to_string(),
|
||||||
|
only_name: "TestModel".to_string(),
|
||||||
|
year: 2020,
|
||||||
|
rq: 85,
|
||||||
|
prize: false,
|
||||||
|
guid: "guid_test".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let times_dir = dir.path().to_str().unwrap();
|
||||||
|
let file_path = dir.path().join("TestCar_2020.json");
|
||||||
|
|
||||||
|
// "t" missing entirely, null, and a string — all must be skipped
|
||||||
|
// without panicking or inserting a record.
|
||||||
|
let json_content = r#"{
|
||||||
|
"data": {
|
||||||
|
"111": {
|
||||||
|
"times": {
|
||||||
|
"laguna_a00": { "u": "Player1" },
|
||||||
|
"mnCity_a01": { "t": null, "u": "Player2" },
|
||||||
|
"testBowl_a02": { "t": "112.45", "u": "Player3" },
|
||||||
|
"butte_a01": { "t": 50.0, "u": "Player4" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
fs::write(&file_path, json_content).unwrap();
|
||||||
|
|
||||||
|
let my_cars: HashSet<String> = HashSet::new();
|
||||||
|
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||||
|
|
||||||
|
// Only the valid butte entry survives.
|
||||||
|
assert!(!ctx.best_cars.contains_key(&("Laguna Seca".to_string(), 85, "Dry".to_string())));
|
||||||
|
assert!(!ctx.best_cars.contains_key(&(
|
||||||
|
"Monaco Streets".to_string(),
|
||||||
|
85,
|
||||||
|
"Wet".to_string()
|
||||||
|
)));
|
||||||
|
assert!(
|
||||||
|
ctx.best_cars.contains_key(&("Butte".to_string(), 85, "Wet".to_string())),
|
||||||
|
"valid time entry should still be parsed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_car_times_filters_downvoted() {
|
fn test_parse_car_times_filters_downvoted() {
|
||||||
let ctx = AnalysisContext::new();
|
let ctx = AnalysisContext::new();
|
||||||
|
|
@ -630,6 +770,30 @@ mod tests {
|
||||||
assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file");
|
assert_eq!(ctx.best_cars.len(), 0, "No entries for missing file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_car_times_empty_file_treated_as_missing() {
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
let car = Car {
|
||||||
|
rid: "EmptyCar_2020".to_string(),
|
||||||
|
brand: "TestBrand".to_string(),
|
||||||
|
only_name: "TestModel".to_string(),
|
||||||
|
year: 2020,
|
||||||
|
rq: 85,
|
||||||
|
prize: false,
|
||||||
|
guid: "guid_test".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let times_dir = dir.path().to_str().unwrap();
|
||||||
|
fs::write(dir.path().join("EmptyCar_2020.json"), "").unwrap();
|
||||||
|
|
||||||
|
let my_cars: HashSet<String> = HashSet::new();
|
||||||
|
// Empty file must be Ok(()) like a missing file, not a parse error.
|
||||||
|
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ctx.best_cars.len(), 0, "No entries for empty file");
|
||||||
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
// Track name / type tests (from lib)
|
// Track name / type tests (from lib)
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
use top_drives::AppError;
|
use top_drives::AppError;
|
||||||
|
|
||||||
|
use crate::commands::TrashParams;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants (CLI defaults)
|
// Constants (CLI defaults)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -104,8 +106,16 @@ enum Commands {
|
||||||
#[arg(long, default_value_t = 10)]
|
#[arg(long, default_value_t = 10)]
|
||||||
min_count: usize,
|
min_count: usize,
|
||||||
},
|
},
|
||||||
/// Generate trash.csv (owned cars not best on any track)
|
/// Generate trash.csv (owned cars not best on any track, plus low-value upgrade fodder)
|
||||||
Trash,
|
Trash {
|
||||||
|
#[command(flatten)]
|
||||||
|
trash: TrashParams,
|
||||||
|
},
|
||||||
|
/// Generate protect.csv and storage.csv (unlocked cars: keep vs. trash recommendation)
|
||||||
|
Protect {
|
||||||
|
#[command(flatten)]
|
||||||
|
trash: TrashParams,
|
||||||
|
},
|
||||||
/// Generate wishlist.csv, add.csv, and remove.csv
|
/// Generate wishlist.csv, add.csv, and remove.csv
|
||||||
Wishlist {
|
Wishlist {
|
||||||
/// Minimum number of tracks a car must be best on to qualify (default: 10)
|
/// Minimum number of tracks a car must be best on to qualify (default: 10)
|
||||||
|
|
@ -123,11 +133,20 @@ enum Commands {
|
||||||
/// Maximum number of cars on the wishlist (default: 300)
|
/// Maximum number of cars on the wishlist (default: 300)
|
||||||
#[arg(long, default_value_t = 300)]
|
#[arg(long, default_value_t = 300)]
|
||||||
max_wishlist: usize,
|
max_wishlist: usize,
|
||||||
|
#[command(flatten)]
|
||||||
|
trash: TrashParams,
|
||||||
},
|
},
|
||||||
/// Generate details.csv with all timing data
|
/// Generate details.csv with all timing data
|
||||||
Details,
|
Details,
|
||||||
/// Show best car for a specific track/condition/RQ combination
|
/// Show best car for a specific track/condition/RQ combination
|
||||||
Track { track: String, condition: String, rq: u8 },
|
Track {
|
||||||
|
/// Human-readable track name (e.g. "Laguna Seca", not the raw code "laguna")
|
||||||
|
track: String,
|
||||||
|
/// Human-readable surface condition (e.g. "Dry", "Wet"; not the raw code "00")
|
||||||
|
condition: String,
|
||||||
|
/// RQ level (car's RQ up to 113)
|
||||||
|
rq: u8,
|
||||||
|
},
|
||||||
/// Generate improve.csv (owned cars not in final config, sorted by best tracks)
|
/// Generate improve.csv (owned cars not in final config, sorted by best tracks)
|
||||||
Improve,
|
Improve,
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +169,10 @@ enum Commands {
|
||||||
/// ```
|
/// ```
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
// Default to warn level so library/bin warnings stay visible without RUST_LOG;
|
||||||
|
// RUST_LOG can still silence or raise verbosity.
|
||||||
|
#[cfg(feature = "bin-logging")]
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||||
if let Err(e) = run().await {
|
if let Err(e) = run().await {
|
||||||
eprintln!("Error: {e}");
|
eprintln!("Error: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
|
|
@ -177,9 +200,25 @@ async fn run() -> Result<(), AppError> {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Commands::Trash => {
|
Commands::Trash { trash } => {
|
||||||
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir)
|
commands::trash::run_trash(
|
||||||
.await?;
|
&cli.my_cars,
|
||||||
|
&cli.cars_db,
|
||||||
|
&cli.times_dir,
|
||||||
|
&cli.output_dir,
|
||||||
|
&trash,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Commands::Protect { trash } => {
|
||||||
|
commands::protect::run_protect(
|
||||||
|
&cli.my_cars,
|
||||||
|
&cli.cars_db,
|
||||||
|
&cli.times_dir,
|
||||||
|
&cli.output_dir,
|
||||||
|
&trash,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Commands::Wishlist { min_count, max_wishlist } => {
|
Commands::Wishlist { min_count, max_wishlist } => {
|
||||||
commands::wishlist::run_wishlist(
|
commands::wishlist::run_wishlist(
|
||||||
|
|
@ -192,7 +231,7 @@ async fn run() -> Result<(), AppError> {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Commands::All { min_count, max_wishlist } => {
|
Commands::All { min_count, max_wishlist, trash } => {
|
||||||
commands::all::run_all(
|
commands::all::run_all(
|
||||||
&cli.my_cars,
|
&cli.my_cars,
|
||||||
&cli.cars_db,
|
&cli.cars_db,
|
||||||
|
|
@ -200,6 +239,7 @@ async fn run() -> Result<(), AppError> {
|
||||||
&cli.output_dir,
|
&cli.output_dir,
|
||||||
min_count,
|
min_count,
|
||||||
max_wishlist,
|
max_wishlist,
|
||||||
|
&trash,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +257,6 @@ async fn run() -> Result<(), AppError> {
|
||||||
&cli.my_cars,
|
&cli.my_cars,
|
||||||
&cli.cars_db,
|
&cli.cars_db,
|
||||||
&cli.times_dir,
|
&cli.times_dir,
|
||||||
&cli.output_dir,
|
|
||||||
&track,
|
&track,
|
||||||
&condition,
|
&condition,
|
||||||
rq,
|
rq,
|
||||||
|
|
|
||||||
65
src/types.rs
65
src/types.rs
|
|
@ -29,6 +29,16 @@ pub enum AppError {
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// The server responded with 200 but an empty body.
|
||||||
|
///
|
||||||
|
/// The records API uses this to signal "known resource, no data submitted yet"
|
||||||
|
/// (typically newly released prize cars). Deterministic — retrying cannot help.
|
||||||
|
#[error("Empty response from {url} (no data available on the server)")]
|
||||||
|
EmptyResponse {
|
||||||
|
/// The URL that returned the empty body
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// A required data file is missing
|
/// A required data file is missing
|
||||||
#[error("Missing data file: {0}")]
|
#[error("Missing data file: {0}")]
|
||||||
MissingFile(String),
|
MissingFile(String),
|
||||||
|
|
@ -132,7 +142,7 @@ pub fn normalize_to_final_build(config: &str) -> String {
|
||||||
digits[i] = b - b'0';
|
digits[i] = b - b'0';
|
||||||
}
|
}
|
||||||
|
|
||||||
let sum: i32 = digits.iter().map(|&d| d as i32).sum();
|
let sum: u8 = digits.iter().sum();
|
||||||
if sum >= 8 {
|
if sum >= 8 {
|
||||||
// Already a final build (sum == 8) or maxed (sum > 8) — return as-is.
|
// Already a final build (sum == 8) or maxed (sum > 8) — return as-is.
|
||||||
return config.to_string();
|
return config.to_string();
|
||||||
|
|
@ -152,8 +162,55 @@ pub fn normalize_to_final_build(config: &str) -> String {
|
||||||
/// Returns `true` if the config is a final build (digit sum == 8) or maxed (sum > 8).
|
/// Returns `true` if the config is a final build (digit sum == 8) or maxed (sum > 8).
|
||||||
///
|
///
|
||||||
/// Configs with sum < 8 are non-final and still have upgrade steps remaining.
|
/// Configs with sum < 8 are non-final and still have upgrade steps remaining.
|
||||||
/// Uses [`normalize_to_final_build`] internally: if normalization returns the
|
/// Malformed configs (not exactly 3 ASCII digits) are treated as non-final so
|
||||||
/// original string unchanged, the config is already at or beyond a final build.
|
/// they surface in improve.csv instead of being silently classified as finished.
|
||||||
|
///
|
||||||
|
/// Checks the digits directly — no allocation, unlike going through
|
||||||
|
/// [`normalize_to_final_build`].
|
||||||
pub fn is_final_build(config: &str) -> bool {
|
pub fn is_final_build(config: &str) -> bool {
|
||||||
normalize_to_final_build(config) == config
|
let bytes = config.as_bytes();
|
||||||
|
if bytes.len() != 3 || !bytes.iter().all(|&b| b.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bytes.iter().map(|&b| b - b'0').sum::<u8>() >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::is_final_build;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn final_builds_have_digit_sum_eight() {
|
||||||
|
assert!(is_final_build("323"));
|
||||||
|
assert!(is_final_build("332"));
|
||||||
|
assert!(is_final_build("233"));
|
||||||
|
assert!(is_final_build("521"));
|
||||||
|
assert!(is_final_build("800"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maxed_builds_beyond_eight_are_final() {
|
||||||
|
assert!(is_final_build("879"));
|
||||||
|
assert!(is_final_build("999"));
|
||||||
|
assert!(is_final_build("990"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfinished_builds_below_eight_are_not_final() {
|
||||||
|
assert!(!is_final_build("111"));
|
||||||
|
assert!(!is_final_build("221"));
|
||||||
|
assert!(!is_final_build("610"));
|
||||||
|
assert!(!is_final_build("070"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_configs_are_not_final() {
|
||||||
|
assert!(!is_final_build(""));
|
||||||
|
assert!(!is_final_build("12"));
|
||||||
|
assert!(!is_final_build("1234"));
|
||||||
|
assert!(!is_final_build("abc"));
|
||||||
|
assert!(!is_final_build("3a3"));
|
||||||
|
assert!(!is_final_build(" 33"));
|
||||||
|
assert!(!is_final_build("33 "));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
src/user_data.rs
132
src/user_data.rs
|
|
@ -30,6 +30,19 @@ pub struct MyCarsData {
|
||||||
/// Multiple copies of the same car model share the same guid.
|
/// Multiple copies of the same car model share the same guid.
|
||||||
/// `owned` HashSet deduplicates, so this counts duplicates.
|
/// `owned` HashSet deduplicates, so this counts duplicates.
|
||||||
pub copy_count: HashMap<String, usize>,
|
pub copy_count: HashMap<String, usize>,
|
||||||
|
/// GUIDs of deck cards that are NOT locked (guid → number of unlocked copies).
|
||||||
|
/// Union of garage and storage cards; used for the garage-space projection.
|
||||||
|
pub unlocked: HashMap<String, usize>,
|
||||||
|
/// Unlocked cards in the garage (`state != 0`, `locked == false`; guid →
|
||||||
|
/// copy count). Candidates for protect.csv.
|
||||||
|
pub garage_unlocked: HashMap<String, usize>,
|
||||||
|
/// Cards in the storage (`state == 0`, any lock state; guid → copy count).
|
||||||
|
/// Candidates for storage.csv recommendations.
|
||||||
|
pub storage: HashMap<String, usize>,
|
||||||
|
/// Total garage slot capacity (from `user.garageSlots`), 0 if absent.
|
||||||
|
pub garage_slots: usize,
|
||||||
|
/// Occupied garage slots (from `user.garageSlotsUsed`), 0 if absent.
|
||||||
|
pub garage_slots_used: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
|
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
|
||||||
|
|
@ -62,30 +75,55 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
|
||||||
let mut owned: HashSet<String> = HashSet::new();
|
let mut owned: HashSet<String> = HashSet::new();
|
||||||
let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
|
let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
let mut copy_count: HashMap<String, usize> = HashMap::new();
|
let mut copy_count: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut unlocked: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut garage_unlocked: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut storage: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
|
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
|
||||||
for card in deck {
|
for card in deck {
|
||||||
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
|
let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else {
|
||||||
if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) {
|
continue;
|
||||||
owned.insert(guid.to_string());
|
};
|
||||||
*copy_count.entry(guid.to_string()).or_insert(0) += 1;
|
let guid = guid.to_string();
|
||||||
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
|
let locked = card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
// Skip cards with incomplete tune data to avoid false "000" entries
|
// state 0 = storage ("Lager"), anything else = garage.
|
||||||
// that would incorrectly appear as needing upgrade.
|
let in_storage = card.get("state").and_then(|v| v.as_i64()) == Some(0);
|
||||||
let engine = card.get("engineMajor").and_then(|v| v.as_u64());
|
if locked {
|
||||||
let weight = card.get("weightMajor").and_then(|v| v.as_u64());
|
owned.insert(guid.clone());
|
||||||
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
|
*copy_count.entry(guid.clone()).or_insert(0) += 1;
|
||||||
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
|
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
|
||||||
let config = format!("{e}{w}{c}");
|
// Skip cards with incomplete tune data to avoid false "000" entries
|
||||||
owned_configs.entry(guid.to_string()).or_default().push(config);
|
// that would incorrectly appear as needing upgrade.
|
||||||
} else {
|
let engine = card.get("engineMajor").and_then(|v| v.as_u64());
|
||||||
eprintln!("Warning: locked card {guid} has incomplete tune data — skipped");
|
let weight = card.get("weightMajor").and_then(|v| v.as_u64());
|
||||||
}
|
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
|
||||||
|
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
|
||||||
|
let config = format!("{e}{w}{c}");
|
||||||
|
owned_configs.entry(guid.clone()).or_default().push(config);
|
||||||
|
} else {
|
||||||
|
log::warn!("locked card {guid} has incomplete tune data — skipped");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
*unlocked.entry(guid.clone()).or_insert(0) += 1;
|
||||||
|
if !in_storage {
|
||||||
|
*garage_unlocked.entry(guid.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in_storage {
|
||||||
|
*storage.entry(guid).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let garage_slots =
|
||||||
|
json.get("user").and_then(|u| u.get("garageSlots")).and_then(|v| v.as_u64()).unwrap_or(0)
|
||||||
|
as usize;
|
||||||
|
let garage_slots_used = json
|
||||||
|
.get("user")
|
||||||
|
.and_then(|u| u.get("garageSlotsUsed"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as usize;
|
||||||
|
|
||||||
// Extract wishlist GUIDs from wishlists[0].cards
|
// Extract wishlist GUIDs from wishlists[0].cards
|
||||||
let wishlist: HashSet<String> = json
|
let wishlist: HashSet<String> = json
|
||||||
.get("wishlists")
|
.get("wishlists")
|
||||||
|
|
@ -95,7 +133,17 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
|
||||||
.map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect())
|
.map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(MyCarsData { owned, wishlist, owned_configs, copy_count })
|
Ok(MyCarsData {
|
||||||
|
owned,
|
||||||
|
wishlist,
|
||||||
|
owned_configs,
|
||||||
|
copy_count,
|
||||||
|
unlocked,
|
||||||
|
garage_unlocked,
|
||||||
|
storage,
|
||||||
|
garage_slots,
|
||||||
|
garage_slots_used,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -111,4 +159,54 @@ mod tests {
|
||||||
other => panic!("Expected MissingFile error, got: {other}"),
|
other => panic!("Expected MissingFile error, got: {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_my_cars_data_parses_deck_and_garage() {
|
||||||
|
let json = r#"{
|
||||||
|
"user": { "garageSlots": 100, "garageSlotsUsed": 90 },
|
||||||
|
"playerDeck": [
|
||||||
|
{ "cardId": "g1", "state": 1, "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 },
|
||||||
|
{ "cardId": "g2", "state": 0, "locked": false },
|
||||||
|
{ "cardId": "g2", "state": 0, "locked": false },
|
||||||
|
{ "cardId": "g3", "state": 1, "locked": false },
|
||||||
|
{ "cardId": "g4", "state": 0, "locked": true }
|
||||||
|
],
|
||||||
|
"wishlists": [ { "cards": ["g9"] } ]
|
||||||
|
}"#;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("my_cars.json");
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
|
||||||
|
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert!(data.owned.contains("g1"));
|
||||||
|
assert!(data.owned.contains("g4"), "locked storage cards still count as owned");
|
||||||
|
assert_eq!(
|
||||||
|
data.owned_configs.get("g1").map(|c| c.as_slice()),
|
||||||
|
Some(&["323".to_string()][..])
|
||||||
|
);
|
||||||
|
assert!(!data.owned.contains("g2"), "unlocked cards must not count as owned");
|
||||||
|
assert_eq!(data.unlocked.get("g2"), Some(&2), "two unlocked copies");
|
||||||
|
assert_eq!(data.unlocked.get("g3"), Some(&1));
|
||||||
|
assert_eq!(data.garage_unlocked.get("g3"), Some(&1), "unlocked garage card");
|
||||||
|
assert!(!data.garage_unlocked.contains_key("g2"), "storage card is not garage");
|
||||||
|
assert_eq!(data.storage.get("g2"), Some(&2), "two storage copies");
|
||||||
|
assert_eq!(data.storage.get("g4"), Some(&1), "locked storage card");
|
||||||
|
assert!(data.wishlist.contains("g9"));
|
||||||
|
assert_eq!(data.garage_slots, 100);
|
||||||
|
assert_eq!(data.garage_slots_used, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_my_cars_data_without_garage_info() {
|
||||||
|
let json = r#"{ "playerDeck": [] }"#;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("my_cars.json");
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
|
||||||
|
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(data.garage_slots, 0);
|
||||||
|
assert_eq!(data.garage_slots_used, 0);
|
||||||
|
assert!(data.unlocked.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,22 +31,23 @@ async fn test_get_file_success() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_file_empty_response_retries_and_fails() {
|
async fn test_get_file_empty_response_fails_immediately_without_retry() {
|
||||||
let mut server = Server::new_async().await;
|
let mut server = Server::new_async().await;
|
||||||
// Return 200 with empty body — triggers retry, then fails
|
// 200 with empty body means "no data available yet" — permanent, so exactly
|
||||||
let m = server
|
// one request must be made (no retries) and a distinguishable error returned.
|
||||||
.mock("GET", "/data.json")
|
let m = server.mock("GET", "/data.json").with_status(200).with_body("").expect(1).create();
|
||||||
.with_status(200)
|
|
||||||
.with_body("")
|
|
||||||
.expect(4) // 1 initial + 3 retries
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let out_path = dir.path().join("output.txt");
|
let out_path = dir.path().join("output.txt");
|
||||||
let url = format!("{}/data.json", server.url());
|
let url = format!("{}/data.json", server.url());
|
||||||
|
|
||||||
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
let result = get_file(&url, out_path.to_str().unwrap()).await;
|
||||||
assert!(result.is_err(), "Empty response should fail after retries");
|
let err = result.expect_err("Empty response should fail immediately");
|
||||||
|
assert!(
|
||||||
|
matches!(err, top_drives::AppError::EmptyResponse { .. }),
|
||||||
|
"Empty response should yield AppError::EmptyResponse, got: {err}"
|
||||||
|
);
|
||||||
|
assert!(!out_path.exists(), "No file should be written for an empty response");
|
||||||
m.assert();
|
m.assert();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ fn test_load_my_cars_data_with_fixture() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
"playerDeck": [
|
"playerDeck": [
|
||||||
{ "cardId": "guid_a", "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 },
|
{ "cardId": "guid_a", "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 },
|
||||||
|
{ "cardId": "guid_a", "locked": true, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
|
||||||
{ "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
|
{ "cardId": "guid_b", "locked": false, "engineMajor": 2, "weightMajor": 3, "chassisMajor": 3 },
|
||||||
{ "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 }
|
{ "cardId": "guid_c", "locked": true, "engineMajor": 1, "weightMajor": 1, "chassisMajor": 1 }
|
||||||
],
|
],
|
||||||
|
|
@ -251,15 +252,19 @@ fn test_load_my_cars_data_with_fixture() {
|
||||||
|
|
||||||
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
let data = load_my_cars_data(path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
// Only locked cards are owned
|
// Only locked cards are owned (set deduplicates duplicate guids)
|
||||||
assert_eq!(data.owned.len(), 2);
|
assert_eq!(data.owned.len(), 2);
|
||||||
assert!(data.owned.contains("guid_a"));
|
assert!(data.owned.contains("guid_a"));
|
||||||
assert!(data.owned.contains("guid_c"));
|
assert!(data.owned.contains("guid_c"));
|
||||||
assert!(!data.owned.contains("guid_b"));
|
assert!(!data.owned.contains("guid_b"));
|
||||||
|
|
||||||
// owned_configs only for locked cards with complete tune data
|
// copy_count tracks duplicates: guid_a has two copies, guid_c one
|
||||||
assert_eq!(data.owned_configs.len(), 2);
|
assert_eq!(data.copy_count.get("guid_a"), Some(&2));
|
||||||
assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string()]));
|
assert_eq!(data.copy_count.get("guid_c"), Some(&1));
|
||||||
|
assert!(!data.copy_count.contains_key("guid_b"));
|
||||||
|
|
||||||
|
// owned_configs collects one entry per copy with complete tune data
|
||||||
|
assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string(), "233".to_string()]));
|
||||||
assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()]));
|
assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()]));
|
||||||
// Unlocked card should not be in owned_configs
|
// Unlocked card should not be in owned_configs
|
||||||
assert!(!data.owned_configs.contains_key("guid_b"));
|
assert!(!data.owned_configs.contains_key("guid_b"));
|
||||||
|
|
@ -289,6 +294,11 @@ fn test_load_my_cars_data_incomplete_tune_data() {
|
||||||
// All locked cards are owned
|
// All locked cards are owned
|
||||||
assert_eq!(data.owned.len(), 3);
|
assert_eq!(data.owned.len(), 3);
|
||||||
|
|
||||||
|
// copy_count counts every locked card, even without complete tune data
|
||||||
|
assert_eq!(data.copy_count.get("guid_full"), Some(&1));
|
||||||
|
assert_eq!(data.copy_count.get("guid_partial"), Some(&1));
|
||||||
|
assert_eq!(data.copy_count.get("guid_empty"), Some(&1));
|
||||||
|
|
||||||
// Only the card with complete tune data has a config entry
|
// Only the card with complete tune data has a config entry
|
||||||
assert_eq!(data.owned_configs.len(), 1);
|
assert_eq!(data.owned_configs.len(), 1);
|
||||||
assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()]));
|
assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()]));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue