|
8 | 8 | use actix_cors::Cors; |
9 | 9 | use actix_web::{App, HttpResponse, HttpServer, Responder, web}; |
10 | 10 | use serde::{Deserialize, Serialize}; |
11 | | -use std::collections::HashMap; |
| 11 | +use std::collections::{HashMap, HashSet}; |
12 | 12 | use std::sync::Arc; |
13 | 13 |
|
14 | 14 | use crate::models::{ |
15 | | - DEFAULT_SPAWNS, DistanceDecay, GamePhase, OptimizerConfig, PurityOverride, SearchStrategy, |
16 | | - UtilityFunction, |
| 15 | + DEFAULT_SPAWNS, DistanceDecay, GamePhase, OptimizerConfig, PurityOverride, ResourceNode, |
| 16 | + SearchStrategy, UtilityFunction, |
17 | 17 | }; |
18 | 18 | use crate::optimizer; |
19 | 19 |
|
@@ -114,6 +114,52 @@ fn apply_collectibles_weights(weights: &mut HashMap<String, f64>) { |
114 | 114 | weights.insert("baconagaric".to_string(), 0.0); |
115 | 115 | } |
116 | 116 |
|
| 117 | +fn validate_optimize_request(req: &OptimizeRequest, nodes: &[ResourceNode]) -> Result<(), String> { |
| 118 | + if !req.sigma.is_finite() { |
| 119 | + return Err("Invalid optimize request: sigma must be finite".to_string()); |
| 120 | + } |
| 121 | + |
| 122 | + let mut meaningful_weights = Vec::new(); |
| 123 | + for (resource_id, weight) in &req.weights { |
| 124 | + if !weight.is_finite() { |
| 125 | + return Err("Invalid optimize request: weight must be finite".to_string()); |
| 126 | + } |
| 127 | + if *weight != 0.0 { |
| 128 | + meaningful_weights.push(resource_id.as_str()); |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + if meaningful_weights.is_empty() { |
| 133 | + return Err( |
| 134 | + "Invalid optimize request: at least one resource weight is required".to_string(), |
| 135 | + ); |
| 136 | + } |
| 137 | + |
| 138 | + let mut known_resource_ids = nodes |
| 139 | + .iter() |
| 140 | + .map(|node| node.resource_type.as_str()) |
| 141 | + .collect::<HashSet<_>>(); |
| 142 | + known_resource_ids.insert("water"); |
| 143 | + |
| 144 | + for resource_id in &meaningful_weights { |
| 145 | + if !known_resource_ids.contains(resource_id) { |
| 146 | + return Err("Invalid optimize request: unknown resource id".to_string()); |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + let resource_universe_size = nodes |
| 151 | + .iter() |
| 152 | + .map(|node| node.resource_type.as_str()) |
| 153 | + .chain(meaningful_weights) |
| 154 | + .collect::<HashSet<_>>() |
| 155 | + .len(); |
| 156 | + if resource_universe_size > 128 { |
| 157 | + return Err("Invalid optimize request: too many resource types".to_string()); |
| 158 | + } |
| 159 | + |
| 160 | + Ok(()) |
| 161 | +} |
| 162 | + |
117 | 163 | // --------------------------------------------------------------------------- |
118 | 164 | // Phase preset builder (mirrors JS PRESETS) |
119 | 165 | // --------------------------------------------------------------------------- |
@@ -186,6 +232,10 @@ async fn post_optimize( |
186 | 232 | ) -> impl Responder { |
187 | 233 | let req = body.into_inner(); |
188 | 234 |
|
| 235 | + if let Err(message) = validate_optimize_request(&req, &state.nodes) { |
| 236 | + return HttpResponse::BadRequest().body(message); |
| 237 | + } |
| 238 | + |
189 | 239 | // Build OptimizerConfig from request |
190 | 240 | let mut config = OptimizerConfig { |
191 | 241 | sigma: req.sigma.clamp(50.0, 1000.0), |
@@ -266,6 +316,8 @@ pub async fn run_server(port: u16) -> std::io::Result<()> { |
266 | 316 | #[cfg(test)] |
267 | 317 | mod tests { |
268 | 318 | use super::*; |
| 319 | + use crate::models::Purity; |
| 320 | + use actix_web::{App, http::StatusCode, test as actix_test}; |
269 | 321 |
|
270 | 322 | #[test] |
271 | 323 | fn presets_include_collectibles_and_late_water_wells() { |
@@ -339,4 +391,153 @@ mod tests { |
339 | 391 | assert!(preset.ignore_spawns); |
340 | 392 | } |
341 | 393 | } |
| 394 | + |
| 395 | + fn test_nodes() -> Vec<ResourceNode> { |
| 396 | + vec![ResourceNode { |
| 397 | + resource_type: "iron".to_string(), |
| 398 | + purity: Purity::Normal, |
| 399 | + x: 0.0, |
| 400 | + y: 0.0, |
| 401 | + z: 0.0, |
| 402 | + obstructed: false, |
| 403 | + }] |
| 404 | + } |
| 405 | + |
| 406 | + fn test_request(weights: &[(&str, f64)]) -> OptimizeRequest { |
| 407 | + OptimizeRequest { |
| 408 | + utility_func: "cobb_douglas".to_string(), |
| 409 | + decay_func: "gaussian".to_string(), |
| 410 | + purity_override: "default".to_string(), |
| 411 | + strategy: "hybrid".to_string(), |
| 412 | + game_phase: "phase1".to_string(), |
| 413 | + sigma: 200.0, |
| 414 | + ignore_spawns: false, |
| 415 | + weights: weights |
| 416 | + .iter() |
| 417 | + .map(|(resource_id, weight)| (resource_id.to_string(), *weight)) |
| 418 | + .collect(), |
| 419 | + } |
| 420 | + } |
| 421 | + |
| 422 | + #[test] |
| 423 | + fn validation_rejects_empty_weights() { |
| 424 | + let nodes = test_nodes(); |
| 425 | + let req = test_request(&[]); |
| 426 | + |
| 427 | + assert!(validate_optimize_request(&req, &nodes).is_err()); |
| 428 | + } |
| 429 | + |
| 430 | + #[test] |
| 431 | + fn validation_rejects_all_zero_weights() { |
| 432 | + let nodes = test_nodes(); |
| 433 | + let req = test_request(&[("iron", 0.0), ("water", 0.0)]); |
| 434 | + |
| 435 | + assert!(validate_optimize_request(&req, &nodes).is_err()); |
| 436 | + } |
| 437 | + |
| 438 | + #[test] |
| 439 | + fn validation_rejects_unknown_resource_ids() { |
| 440 | + let nodes = test_nodes(); |
| 441 | + let req = test_request(&[("unknown", 1.0)]); |
| 442 | + |
| 443 | + assert!(validate_optimize_request(&req, &nodes).is_err()); |
| 444 | + } |
| 445 | + |
| 446 | + #[test] |
| 447 | + fn validation_rejects_non_finite_values() { |
| 448 | + let nodes = test_nodes(); |
| 449 | + let mut req = test_request(&[("iron", 1.0)]); |
| 450 | + req.sigma = f64::INFINITY; |
| 451 | + assert!(validate_optimize_request(&req, &nodes).is_err()); |
| 452 | + |
| 453 | + let req = test_request(&[("iron", f64::NAN)]); |
| 454 | + assert!(validate_optimize_request(&req, &nodes).is_err()); |
| 455 | + } |
| 456 | + |
| 457 | + #[test] |
| 458 | + fn validation_accepts_known_resource_ids_and_water() { |
| 459 | + let nodes = test_nodes(); |
| 460 | + let req = test_request(&[("iron", 1.0), ("water", 0.5)]); |
| 461 | + |
| 462 | + assert!(validate_optimize_request(&req, &nodes).is_ok()); |
| 463 | + } |
| 464 | + |
| 465 | + #[test] |
| 466 | + fn validation_rejects_resource_universe_over_fixed_limit() { |
| 467 | + let nodes = (0..129) |
| 468 | + .map(|index| ResourceNode { |
| 469 | + resource_type: format!("resource{index}"), |
| 470 | + purity: Purity::Normal, |
| 471 | + x: 0.0, |
| 472 | + y: 0.0, |
| 473 | + z: 0.0, |
| 474 | + obstructed: false, |
| 475 | + }) |
| 476 | + .collect::<Vec<_>>(); |
| 477 | + let req = test_request(&[("resource0", 1.0)]); |
| 478 | + |
| 479 | + assert!(validate_optimize_request(&req, &nodes).is_err()); |
| 480 | + } |
| 481 | + |
| 482 | + #[actix_web::test] |
| 483 | + async fn invalid_optimize_request_returns_bad_request() { |
| 484 | + let app_state = Arc::new(AppState { |
| 485 | + nodes: test_nodes(), |
| 486 | + }); |
| 487 | + let app = actix_test::init_service( |
| 488 | + App::new() |
| 489 | + .app_data(web::Data::new(app_state)) |
| 490 | + .route("/api/optimize", web::post().to(post_optimize)), |
| 491 | + ) |
| 492 | + .await; |
| 493 | + |
| 494 | + let request = actix_test::TestRequest::post() |
| 495 | + .uri("/api/optimize") |
| 496 | + .set_json(serde_json::json!({ |
| 497 | + "utility_func": "cobb_douglas", |
| 498 | + "decay_func": "gaussian", |
| 499 | + "purity_override": "default", |
| 500 | + "strategy": "hybrid", |
| 501 | + "game_phase": "phase1", |
| 502 | + "sigma": 200.0, |
| 503 | + "ignore_spawns": false, |
| 504 | + "weights": {} |
| 505 | + })) |
| 506 | + .to_request(); |
| 507 | + let response = actix_test::call_service(&app, request).await; |
| 508 | + |
| 509 | + assert_eq!(response.status(), StatusCode::BAD_REQUEST); |
| 510 | + } |
| 511 | + |
| 512 | + #[actix_web::test] |
| 513 | + async fn valid_optimize_request_returns_ok() { |
| 514 | + let app_state = Arc::new(AppState { |
| 515 | + nodes: crate::data_loader::load_default_nodes(), |
| 516 | + }); |
| 517 | + let app = actix_test::init_service( |
| 518 | + App::new() |
| 519 | + .app_data(web::Data::new(app_state)) |
| 520 | + .route("/api/optimize", web::post().to(post_optimize)), |
| 521 | + ) |
| 522 | + .await; |
| 523 | + |
| 524 | + let request = actix_test::TestRequest::post() |
| 525 | + .uri("/api/optimize") |
| 526 | + .set_json(serde_json::json!({ |
| 527 | + "utility_func": "cobb_douglas", |
| 528 | + "decay_func": "gaussian", |
| 529 | + "purity_override": "default", |
| 530 | + "strategy": "hybrid", |
| 531 | + "game_phase": "phase1", |
| 532 | + "sigma": 200.0, |
| 533 | + "ignore_spawns": false, |
| 534 | + "weights": { |
| 535 | + "iron": 1.0 |
| 536 | + } |
| 537 | + })) |
| 538 | + .to_request(); |
| 539 | + let response = actix_test::call_service(&app, request).await; |
| 540 | + |
| 541 | + assert_eq!(response.status(), StatusCode::OK); |
| 542 | + } |
342 | 543 | } |
0 commit comments