@@ -138,6 +138,64 @@ fn distance_to_nearest_water(
138138 min_dist_m
139139}
140140
141+ fn decay_weight (
142+ distance_m : f64 ,
143+ distance_sq_m : f64 ,
144+ sigma : f64 ,
145+ decay_func : crate :: models:: DistanceDecay ,
146+ ) -> f64 {
147+ match decay_func {
148+ crate :: models:: DistanceDecay :: Gaussian => {
149+ let two_sigma_sq = 2.0 * sigma * sigma;
150+ ( -distance_sq_m / two_sigma_sq) . exp ( )
151+ }
152+ crate :: models:: DistanceDecay :: Exponential => ( -distance_m / sigma) . exp ( ) ,
153+ crate :: models:: DistanceDecay :: PowerLaw => 1.0 / ( distance_m / sigma + 1.0 ) ,
154+ crate :: models:: DistanceDecay :: Linear => ( 1.0 - distance_m / sigma) . max ( 0.0 ) ,
155+ crate :: models:: DistanceDecay :: LogisticStep => {
156+ if distance_m <= sigma {
157+ 1.0
158+ } else {
159+ 0.05
160+ }
161+ }
162+ }
163+ }
164+
165+ fn obstructed_node_contributes ( obstructed : bool , game_phase : crate :: models:: GamePhase ) -> bool {
166+ !obstructed
167+ || ( game_phase != crate :: models:: GamePhase :: Phase1
168+ && game_phase != crate :: models:: GamePhase :: Phase2 )
169+ }
170+
171+ fn node_yield_contribution (
172+ node : & OptNode ,
173+ decay : f64 ,
174+ game_phase : crate :: models:: GamePhase ,
175+ ) -> f64 {
176+ if obstructed_node_contributes ( node. obstructed , game_phase) {
177+ node. multiplier * decay
178+ } else {
179+ 0.0
180+ }
181+ }
182+
183+ fn virtual_water_yield (
184+ x : f64 ,
185+ y : f64 ,
186+ opt_nodes : & [ OptNode ] ,
187+ waterwell_idx : Option < usize > ,
188+ config : & OptimizerConfig ,
189+ ) -> f64 {
190+ let water_dist = distance_to_nearest_water ( x, y, opt_nodes, waterwell_idx) ;
191+ decay_weight (
192+ water_dist,
193+ water_dist * water_dist,
194+ config. sigma ,
195+ config. decay_func ,
196+ )
197+ }
198+
141199/// Estimates the ground altitude Z at a coordinate (x, y) using KNN IDW (Inverse Distance Weighting)
142200fn estimate_altitude (
143201 x : f64 ,
@@ -432,32 +490,8 @@ fn calculate_utility(
432490
433491 if d_sq <= radius_m_sq {
434492 let d = d_sq. sqrt ( ) ;
435- let decay = match config. decay_func {
436- crate :: models:: DistanceDecay :: Gaussian => {
437- let two_sigma_sq = 2.0 * config. sigma * config. sigma ;
438- ( -d_sq / two_sigma_sq) . exp ( )
439- }
440- crate :: models:: DistanceDecay :: Exponential => ( -d / config. sigma ) . exp ( ) ,
441- crate :: models:: DistanceDecay :: PowerLaw => 1.0 / ( d / config. sigma + 1.0 ) ,
442- crate :: models:: DistanceDecay :: Linear => ( 1.0 - d / config. sigma ) . max ( 0.0 ) ,
443- crate :: models:: DistanceDecay :: LogisticStep => {
444- if d <= config. sigma {
445- 1.0
446- } else {
447- 0.05
448- }
449- }
450- } ;
451- let mut contribution = node. multiplier * decay;
452-
453- // Obstructed nodes are locked behind Nobelisk explosives (Phase 1 & 2)
454- if node. obstructed
455- && ( config. game_phase == crate :: models:: GamePhase :: Phase1
456- || config. game_phase == crate :: models:: GamePhase :: Phase2 )
457- {
458- contribution = 0.0 ;
459- }
460-
493+ let decay = decay_weight ( d, d_sq, config. sigma , config. decay_func ) ;
494+ let contribution = node_yield_contribution ( node, decay, config. game_phase ) ;
461495 yields[ node. res_idx ] += contribution;
462496
463497 // Keep track of nearby heights using Welford's algorithm
@@ -475,24 +509,7 @@ fn calculate_utility(
475509
476510 // Add virtual water yield based on proximity to mapped lakes/ponds or waterwells.
477511 if let Some ( & water_idx) = res_to_idx. get ( "water" ) {
478- let water_dist = distance_to_nearest_water ( x, y, opt_nodes, waterwell_idx) ;
479- let water_decay = match config. decay_func {
480- crate :: models:: DistanceDecay :: Gaussian => {
481- let two_sigma_sq = 2.0 * config. sigma * config. sigma ;
482- ( -( water_dist * water_dist) / two_sigma_sq) . exp ( )
483- }
484- crate :: models:: DistanceDecay :: Exponential => ( -water_dist / config. sigma ) . exp ( ) ,
485- crate :: models:: DistanceDecay :: PowerLaw => 1.0 / ( water_dist / config. sigma + 1.0 ) ,
486- crate :: models:: DistanceDecay :: Linear => ( 1.0 - water_dist / config. sigma ) . max ( 0.0 ) ,
487- crate :: models:: DistanceDecay :: LogisticStep => {
488- if water_dist <= config. sigma {
489- 1.0
490- } else {
491- 0.05
492- }
493- }
494- } ;
495- yields[ water_idx] = water_decay;
512+ yields[ water_idx] = virtual_water_yield ( x, y, opt_nodes, waterwell_idx, config) ;
496513 }
497514
498515 // 3. Terrain Flatness Penalty (Population std dev of local node heights)
@@ -771,23 +788,11 @@ fn run_hill_climbing(
771788
772789 // Accumulate decay-weighted yield for this resource type
773790 let d_m = d_sq_3d. sqrt ( ) / 100.0 ;
774- let decay = match config. decay_func {
775- crate :: models:: DistanceDecay :: Gaussian => {
776- let two_sigma_sq = 2.0 * config. sigma * config. sigma ;
777- ( -( d_m * d_m) / two_sigma_sq) . exp ( )
778- }
779- crate :: models:: DistanceDecay :: Exponential => ( -d_m / config. sigma ) . exp ( ) ,
780- crate :: models:: DistanceDecay :: PowerLaw => 1.0 / ( d_m / config. sigma + 1.0 ) ,
781- crate :: models:: DistanceDecay :: Linear => ( 1.0 - d_m / config. sigma ) . max ( 0.0 ) ,
782- crate :: models:: DistanceDecay :: LogisticStep => {
783- if d_m <= config. sigma {
784- 1.0
785- } else {
786- 0.05
787- }
788- }
789- } ;
790- * resource_yields. entry ( name. clone ( ) ) . or_insert ( 0.0 ) += node. multiplier * decay;
791+ let decay = decay_weight ( d_m, d_m * d_m, config. sigma , config. decay_func ) ;
792+ let contribution = node_yield_contribution ( node, decay, config. game_phase ) ;
793+ if contribution > 0.0 {
794+ * resource_yields. entry ( name. clone ( ) ) . or_insert ( 0.0 ) += contribution;
795+ }
791796 }
792797 }
793798
@@ -798,6 +803,13 @@ fn run_hill_climbing(
798803 }
799804 }
800805
806+ if res_to_idx. contains_key ( "water" ) {
807+ let water_yield = virtual_water_yield ( curr_x, curr_y, opt_nodes, waterwell_idx, config) ;
808+ if water_yield > 0.0 {
809+ resource_yields. insert ( "water" . to_string ( ) , water_yield) ;
810+ }
811+ }
812+
801813 let terrain_ruggedness = if tri_count > 0 {
802814 tri_sum / tri_count as f64
803815 } else {
@@ -1209,7 +1221,30 @@ pub fn optimize(nodes: &[ResourceNode], config: &OptimizerConfig) -> Vec<Optimiz
12091221#[ cfg( test) ]
12101222mod tests {
12111223 use super :: * ;
1212- use crate :: models:: { GamePhase , OptimizerConfig } ;
1224+ use crate :: models:: { GamePhase , OptimizerConfig , Purity , ResourceNode } ;
1225+ use std:: collections:: HashMap ;
1226+
1227+ fn node ( resource_type : & str , x : f64 , y : f64 , obstructed : bool ) -> ResourceNode {
1228+ ResourceNode {
1229+ resource_type : resource_type. to_string ( ) ,
1230+ purity : Purity :: Normal ,
1231+ x,
1232+ y,
1233+ z : 0.0 ,
1234+ obstructed,
1235+ }
1236+ }
1237+
1238+ fn config_for ( resources : & [ ( & str , f64 ) ] ) -> OptimizerConfig {
1239+ let mut config = OptimizerConfig :: default ( ) ;
1240+ config. weights = HashMap :: new ( ) ;
1241+ for ( name, weight) in resources {
1242+ config. weights . insert ( ( * name) . to_string ( ) , * weight) ;
1243+ }
1244+ config. strategy = crate :: models:: SearchStrategy :: Fast ;
1245+ config. ignore_spawns = true ;
1246+ config
1247+ }
12131248
12141249 #[ test]
12151250 fn test_ignore_spawns ( ) {
@@ -1257,6 +1292,61 @@ mod tests {
12571292 assert ! ( ignored_score > constrained_score) ;
12581293 }
12591294
1295+ #[ test]
1296+ fn resource_yields_exclude_early_phase_obstructed_nodes ( ) {
1297+ let nodes = vec ! [ node( "iron" , 0.0 , 0.0 , true ) ] ;
1298+ let mut config = config_for ( & [ ( "iron" , 1.0 ) ] ) ;
1299+ config. game_phase = GamePhase :: Phase1 ;
1300+ config. sigma = 500.0 ;
1301+
1302+ let ctx = prepare_context ( & nodes, & config) ;
1303+ let result = run_hill_climbing (
1304+ 0.0 ,
1305+ 0.0 ,
1306+ & ctx. opt_nodes ,
1307+ & ctx. spatial_grid ,
1308+ & config,
1309+ ctx. num_resources ,
1310+ & ctx. weights_arr ,
1311+ & ctx. epsilons_arr ,
1312+ & ctx. res_to_idx ,
1313+ ctx. waterwell_idx ,
1314+ & ctx. land_mask ,
1315+ ) ;
1316+
1317+ assert_eq ! ( result. obstructed_nodes. get( "Normal iron" ) , Some ( & 1 ) ) ;
1318+ assert_eq ! ( result. local_nodes. get( "Normal iron" ) , None ) ;
1319+ assert_eq ! (
1320+ result. resource_yields. get( "iron" ) . copied( ) . unwrap_or( 0.0 ) ,
1321+ 0.0
1322+ ) ;
1323+ }
1324+
1325+ #[ test]
1326+ fn resource_yields_include_virtual_static_water ( ) {
1327+ let nodes = vec ! [ node( "iron" , 140000.0 , 230000.0 , false ) ] ;
1328+ let mut config = config_for ( & [ ( "iron" , 0.1 ) , ( "water" , 1.0 ) ] ) ;
1329+ config. game_phase = GamePhase :: Phase2 ;
1330+ config. sigma = 500.0 ;
1331+
1332+ let ctx = prepare_context ( & nodes, & config) ;
1333+ let result = run_hill_climbing (
1334+ 140000.0 ,
1335+ 230000.0 ,
1336+ & ctx. opt_nodes ,
1337+ & ctx. spatial_grid ,
1338+ & config,
1339+ ctx. num_resources ,
1340+ & ctx. weights_arr ,
1341+ & ctx. epsilons_arr ,
1342+ & ctx. res_to_idx ,
1343+ ctx. waterwell_idx ,
1344+ & ctx. land_mask ,
1345+ ) ;
1346+
1347+ assert ! ( result. resource_yields. get( "water" ) . copied( ) . unwrap_or( 0.0 ) > 0.0 ) ;
1348+ }
1349+
12601350 #[ test]
12611351 fn test_default_nodes_optimize ( ) {
12621352 let nodes = crate :: data_loader:: load_default_nodes ( ) ;
0 commit comments