Skip to content

Commit 4a8694f

Browse files
committed
Validate optimize requests
1 parent 5852a3b commit 4a8694f

4 files changed

Lines changed: 235 additions & 6 deletions

File tree

src/app.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// FICSIT Starting Position Optimizer
22
// Web Dashboard Logic
33

4-
import { parseNodes, RESOURCES } from "./mapContracts.js";
4+
import { hasOptimizationObjective, nonZeroWeights, parseNodes, RESOURCES } from "./mapContracts.js";
55

66
const LAND_MASK_SECTORS = 128;
77
const LAND_MASK_BUFFER_CM = 22000;
@@ -143,6 +143,14 @@ async function runGlobalOptimization() {
143143
els.mapLoading.classList.add("active");
144144

145145
try {
146+
const weights = nonZeroWeights(config.weights);
147+
if (!hasOptimizationObjective(config.weights)) {
148+
els.mapLoading.innerHTML = `<span style="color: #ff3333; font-weight: bold; font-size: 1.1rem; margin-bottom: 12px;">OPTIMIZATION FAILED: Select at least one weighted resource.</span>
149+
<span style="font-size: 0.8rem; color: var(--color-text-muted);">Enable a resource slider or apply a phase preset, then try again.</span>`;
150+
els.mapLoading.classList.add("active");
151+
return;
152+
}
153+
146154
// Build the request body
147155
const reqBody = {
148156
utility_func: config.utilityFunc,
@@ -152,7 +160,7 @@ async function runGlobalOptimization() {
152160
game_phase: config.gamePhase,
153161
sigma: config.sigma,
154162
ignore_spawns: config.ignoreSpawns,
155-
weights: Object.fromEntries(Object.entries(config.weights).filter(([_, v]) => v !== 0)),
163+
weights,
156164
};
157165

158166
const apiRes = await fetch("/api/optimize", {

src/mapContracts.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ export function parsePurityMultiplier(purity) {
4545
return 1.0;
4646
}
4747

48+
export function nonZeroWeights(weights) {
49+
return Object.fromEntries(Object.entries(weights).filter(([_, value]) => value !== 0));
50+
}
51+
52+
export function hasOptimizationObjective(weights) {
53+
return Object.keys(nonZeroWeights(weights)).length > 0;
54+
}
55+
4856
// Parse Complete Map Data from Raw JSON format
4957
export function parseNodes(data) {
5058
const nodes = [];

src/mapContracts.test.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, test } from "bun:test";
22

3-
import { DEFAULT_PHASE_IDS, parseNodes, RESOURCES } from "./mapContracts.js";
3+
import {
4+
DEFAULT_PHASE_IDS,
5+
hasOptimizationObjective,
6+
nonZeroWeights,
7+
parseNodes,
8+
RESOURCES,
9+
} from "./mapContracts.js";
410

511
describe("map contracts", () => {
612
test("resource IDs are unique", () => {
@@ -59,4 +65,10 @@ describe("map contracts", () => {
5965
});
6066
expect(nodes.waterwellIndices).toEqual([]);
6167
});
68+
69+
test("empty optimization objectives are detectable before API calls", () => {
70+
expect(nonZeroWeights({ iron: 0, copper: 1 })).toEqual({ copper: 1 });
71+
expect(hasOptimizationObjective({ iron: 0, copper: 0 })).toBe(false);
72+
expect(hasOptimizationObjective({ iron: 0, copper: 1 })).toBe(true);
73+
});
6274
});

src/server.rs

Lines changed: 204 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
use actix_cors::Cors;
99
use actix_web::{App, HttpResponse, HttpServer, Responder, web};
1010
use serde::{Deserialize, Serialize};
11-
use std::collections::HashMap;
11+
use std::collections::{HashMap, HashSet};
1212
use std::sync::Arc;
1313

1414
use crate::models::{
15-
DEFAULT_SPAWNS, DistanceDecay, GamePhase, OptimizerConfig, PurityOverride, SearchStrategy,
16-
UtilityFunction,
15+
DEFAULT_SPAWNS, DistanceDecay, GamePhase, OptimizerConfig, PurityOverride, ResourceNode,
16+
SearchStrategy, UtilityFunction,
1717
};
1818
use crate::optimizer;
1919

@@ -114,6 +114,52 @@ fn apply_collectibles_weights(weights: &mut HashMap<String, f64>) {
114114
weights.insert("baconagaric".to_string(), 0.0);
115115
}
116116

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+
117163
// ---------------------------------------------------------------------------
118164
// Phase preset builder (mirrors JS PRESETS)
119165
// ---------------------------------------------------------------------------
@@ -186,6 +232,10 @@ async fn post_optimize(
186232
) -> impl Responder {
187233
let req = body.into_inner();
188234

235+
if let Err(message) = validate_optimize_request(&req, &state.nodes) {
236+
return HttpResponse::BadRequest().body(message);
237+
}
238+
189239
// Build OptimizerConfig from request
190240
let mut config = OptimizerConfig {
191241
sigma: req.sigma.clamp(50.0, 1000.0),
@@ -266,6 +316,8 @@ pub async fn run_server(port: u16) -> std::io::Result<()> {
266316
#[cfg(test)]
267317
mod tests {
268318
use super::*;
319+
use crate::models::Purity;
320+
use actix_web::{App, http::StatusCode, test as actix_test};
269321

270322
#[test]
271323
fn presets_include_collectibles_and_late_water_wells() {
@@ -339,4 +391,153 @@ mod tests {
339391
assert!(preset.ignore_spawns);
340392
}
341393
}
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+
}
342543
}

0 commit comments

Comments
 (0)