diff --git a/src/game/engine/src/entities/improvement.gd b/src/game/engine/src/entities/improvement.gd index 9fd5de76..bf4ca018 100644 --- a/src/game/engine/src/entities/improvement.gd +++ b/src/game/engine/src/entities/improvement.gd @@ -80,13 +80,6 @@ static func get_wind_speed_multiplier(improvement_type: String) -> float: return effects.get("wind_speed_multiplier", 1.0) -static func get_terrain_change(improvement_type: String) -> String: - ## Return target terrain ID if this improvement transforms terrain, else "". - var data: Dictionary = _get_data(improvement_type) - var effects: Dictionary = data.get("effects", {}) - return effects.get("terrain_change", "") - - static func prevents_erosion(improvement_type: String) -> bool: ## Return true if this improvement prevents terrain quality degradation. var data: Dictionary = _get_data(improvement_type) diff --git a/src/game/engine/src/modules/management/improvement_manager.gd b/src/game/engine/src/modules/management/improvement_manager.gd index 9ba1af0d..6d11c7c7 100644 --- a/src/game/engine/src/modules/management/improvement_manager.gd +++ b/src/game/engine/src/modules/management/improvement_manager.gd @@ -134,17 +134,14 @@ func _has_pending_at(tile_pos: Vector2i, player: RefCounted) -> bool: func _on_improvement_completed(tile_pos: Vector2i, improvement_type: String) -> void: ## Apply the completed improvement to the tile. ## - ## Rail 1 (p2-75): the authoritative per-hex write AND all completion - ## *effects* (defense_bonus, concealed_from_surface) are owned by Rust — - ## this method is a thin caller of `GdGameState.complete_improvement`, - ## which parses the improvement's canonical JSON `effects` object and - ## mirrors the typed effects onto the live `TileImprovement`. GDScript no - ## longer computes any improvement effect. - ## - ## RESIDUAL (tracked, not p2-75): the deforestation/reforestation - ## terrain-transform branch still flips `tile.biome_id` in GDScript. That - ## terraforming path (and its hydrology re-solve) is p2-78; it is NOT one - ## of the two anchor effects this objective owns. + ## Rail 1 (p2-75 + terrain-transform follow-up): the authoritative per-hex + ## write, all completion *effects* (defense_bonus, concealed_from_surface), + ## AND the deforestation/reforestation biome transform are owned by Rust — + ## this method is a thin caller of `GdGameState.complete_improvement`, which + ## parses the canonical JSON `effects`, applies them, and (for a transform) + ## flips the grid biome and returns the new biome label. GDScript computes no + ## improvement effect; it only mirrors the result to the renderer tile. + ## (The runtime hydrology re-solve a transform triggers is p2-78.) var game_map: RefCounted = GameState.get_game_map() if game_map == null: push_error("ImprovementManager: No game map when improvement completed") @@ -157,22 +154,26 @@ func _on_improvement_completed(tile_pos: Vector2i, improvement_type: String) -> ) return - # Terrain-transform improvements (deforestation/reforestation) flip biome - # and write no per-hex anchor. Tracked for the p2-78 Rust terraforming port. - var terrain_change: String = ImprovementScript.get_terrain_change(improvement_type) - if terrain_change != "": + # Authoritative completion + effect application + terrain transform in Rust. + var gd_state: RefCounted = GameState.get_gd_state() + if gd_state == null: + push_error("ImprovementManager: No Rust game state when improvement completed") + return + var data: Dictionary = ImprovementScript._get_data(improvement_type) + var new_biome: String = gd_state.complete_improvement( + tile_pos.x, tile_pos.y, JSON.stringify(data) + ) + + if new_biome != "": + # Terrain transform: Rust flipped the grid biome; mirror it to the + # renderer tile (no standing-improvement sprite for a transform). var old_biome: String = tile.biome_id - tile.biome_id = terrain_change - EventBus.terrain_transformed.emit(tile, old_biome, terrain_change) + tile.biome_id = new_biome + EventBus.terrain_transformed.emit(tile, old_biome, new_biome) return - # Authoritative completion + effect application in Rust. - var gd_state: RefCounted = GameState.get_gd_state() - if gd_state != null: - var data: Dictionary = ImprovementScript._get_data(improvement_type) - gd_state.complete_improvement(tile_pos.x, tile_pos.y, JSON.stringify(data)) - - # Presentation mirror: the renderer reads `tile.improvement` off the - # GDScript map (the per-hex read-path migration is out of p2-75 scope). + # Presentation mirror for standing improvements: the renderer reads + # `tile.improvement` off the GDScript map (the per-hex read-path migration + # is out of p2-75 scope). tile.improvement = improvement_type EventBus.tile_improved.emit(tile_pos, improvement_type) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 996ee18f..0e6e9d63 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -4879,28 +4879,36 @@ impl GdGameState { /// per-hex `TileImprovement` (mirroring `defense_bonus` / /// `concealed_from_surface` onto the live instance). Completion /// side-effects are therefore computed in Rust (Rail 1); GDScript is a - /// thin caller. Returns `false` (no write) on coord overflow or JSON - /// parse failure. + /// thin caller. + /// + /// Returns the **new biome label** when the improvement is a terrain + /// transform (deforestation / reforestation): Rust flips the grid tile's + /// biome and writes no standing anchor, and GDScript mirrors the returned + /// label onto the renderer tile without recomputing it. Returns `""` for a + /// standing improvement (the normal case) and also `""` (no write) on coord + /// overflow or JSON parse failure — errors are surfaced via `godot_error!`. #[func] pub fn complete_improvement( &mut self, col: i64, row: i64, improvement_json: GString, - ) -> bool { + ) -> GString { use mc_core::improvement::{RawImprovementJson, TileImprovementSpec}; - let Ok(c) = u16::try_from(col) else { return false }; - let Ok(r) = u16::try_from(row) else { return false }; + let Ok(c) = u16::try_from(col) else { return GString::new() }; + let Ok(r) = u16::try_from(row) else { return GString::new() }; let raw: RawImprovementJson = match serde_json::from_str(&improvement_json.to_string()) { Ok(raw) => raw, Err(e) => { godot_error!("GdGameState::complete_improvement: JSON parse failed: {e}"); - return false; + return GString::new(); } }; let spec = TileImprovementSpec::from_json(&raw); - self.inner.complete_improvement(c, r, &spec); - true + match self.inner.complete_improvement(c, r, &spec) { + Some(new_biome) => GString::from(new_biome), + None => GString::new(), + } } /// p2-75 — read the defense-bonus percent (e.g. 50) carried by the diff --git a/src/simulator/crates/mc-core/src/improvement.rs b/src/simulator/crates/mc-core/src/improvement.rs index 49279c48..3454612c 100644 --- a/src/simulator/crates/mc-core/src/improvement.rs +++ b/src/simulator/crates/mc-core/src/improvement.rs @@ -26,7 +26,7 @@ use std::collections::BTreeSet; /// The struct is extensible: later effect classes (the bunker's /// `destroys_deposit` / `surface_contamination`, p2-76/77) add fields here and /// gain a `#[serde(default)]` for save-compat. `Eq` is intentional — every -/// field is integral/bool, so no float creeps into a `BTreeMap`-keyed save. +/// field is integral / bool / `String`, so no float creeps into a `BTreeMap`-keyed save. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ImprovementEffects { /// Additive defense bonus as an integer **percent** (50 = +50%). 0 = none. @@ -35,6 +35,17 @@ pub struct ImprovementEffects { /// When true, the improvement is concealed from surface observers. #[serde(default)] pub concealed_from_surface: bool, + /// Target biome label this improvement transforms its tile into on + /// completion (deforestation → `grassland`, reforestation → + /// `enchanted_forest`). `None` for standing improvements. This is a + /// **transform**, not a standing structure: `GameState::complete_improvement` + /// flips the grid biome and writes NO `tile_improvements` anchor when set, + /// so it is mutually exclusive with `defense_bonus` / `concealed_from_surface` + /// (those would be silently dropped on a transform). `skip_serializing_if` + /// keeps the serialized bytes of standing-improvement saves/goldens unchanged + /// (a `None` emits nothing rather than `"terrain_change":null`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terrain_change: Option, } impl ImprovementEffects { @@ -110,9 +121,9 @@ pub struct RawImprovementJson { } /// Raw `effects` object as authored in JSON. Carries the parse-only `severable` -/// flag plus the simulation-consumed effect keys. Renderer/economy keys -/// (`movement_cost_modifier`, `moisture_delta`, `terrain_change`, …) are not -/// listed here and are silently dropped. +/// flag plus the simulation-consumed effect keys. Remaining renderer/economy keys +/// (`movement_cost_modifier`, `moisture_delta`, …) are not listed here and are +/// silently dropped. #[derive(Debug, Clone, Deserialize)] pub struct RawImprovementEffects { #[serde(default)] @@ -121,6 +132,10 @@ pub struct RawImprovementEffects { pub defense_bonus: Option, #[serde(default)] pub concealed_from_surface: Option, + /// Target biome label for terrain-transform improvements (deforestation / + /// reforestation). Absent for standing improvements. + #[serde(default)] + pub terrain_change: Option, } impl From<&RawImprovementEffects> for ImprovementEffects { @@ -128,6 +143,7 @@ impl From<&RawImprovementEffects> for ImprovementEffects { Self { defense_bonus: raw.defense_bonus.unwrap_or(0), concealed_from_surface: raw.concealed_from_surface.unwrap_or(false), + terrain_change: raw.terrain_change.clone(), } } } diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 6205091c..4159a7eb 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -2081,14 +2081,14 @@ mod tests { hp: 0, severable: false, flags: BTreeSet::new(), - effects: ImprovementEffects { defense_bonus: 50, concealed_from_surface: false }, + effects: ImprovementEffects { defense_bonus: 50, concealed_from_surface: false, terrain_change: None }, }; let tunnel = TileImprovementSpec { id: "tunnel".into(), hp: 0, severable: false, flags: BTreeSet::new(), - effects: ImprovementEffects { defense_bonus: 0, concealed_from_surface: true }, + effects: ImprovementEffects { defense_bonus: 0, concealed_from_surface: true, terrain_change: None }, }; state.complete_improvement(4, 0, &fort); state.complete_improvement(6, 0, &tunnel); diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index b9083aa6..9e7d9a8e 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -596,11 +596,40 @@ impl GameState { /// parsed `effects` so combat / vision consumers read effects locally off /// the instance without a registry round-trip. /// - /// Determinism: writes are keyed into a `BTreeMap` and the two baseline - /// effects (`defense_bonus`, `concealed_from_surface`) are read-time with - /// no cross-tile dependency, so the completion order across a - /// multi-completion turn does not affect the resulting state. - pub fn complete_improvement(&mut self, col: u16, row: u16, spec: &TileImprovementSpec) { + /// Determinism: writes are keyed into a `BTreeMap` and the baseline effects + /// (`defense_bonus`, `concealed_from_surface`) are read-time with no + /// cross-tile dependency, so the completion order across a multi-completion + /// turn does not affect the resulting state. + /// + /// Terrain transform: when `spec.effects.terrain_change` is set + /// (deforestation / reforestation), this is a one-shot terraform, NOT a + /// standing structure — it flips the grid tile's `biome_label_id` and writes + /// **no** `tile_improvements` anchor, returning `Some(new_biome)` so the + /// caller (the gdext bridge) can mirror the change to the renderer without + /// recomputing it. Standing improvements record an anchor and return `None`. + /// The runtime hydrology re-solve that a real terraform triggers is `p2-78` + /// and deliberately out of scope here — this is the biome-label flip only. + pub fn complete_improvement( + &mut self, + col: u16, + row: u16, + spec: &TileImprovementSpec, + ) -> Option { + if let Some(new_biome) = &spec.effects.terrain_change { + // A transform carries only `terrain_change`; standing effects on the + // same spec would be silently dropped (no anchor is written). + debug_assert!( + spec.effects.defense_bonus == 0 && !spec.effects.concealed_from_surface, + "terrain_change improvement '{}' must not also carry standing effects", + spec.id + ); + if let Some(grid) = self.grid.as_mut() { + if let Some(tile) = grid.tile_mut(i32::from(col), i32::from(row)) { + tile.biome_label_id = new_biome.clone(); + } + } + return Some(new_biome.clone()); + } self.tile_improvements.insert( (col, row), TileImprovement { @@ -612,6 +641,7 @@ impl GameState { effects: spec.effects.clone(), }, ); + None } /// Remove the improvement at `(col, row)` entirely. diff --git a/src/simulator/crates/mc-turn/src/improvement_tests.rs b/src/simulator/crates/mc-turn/src/improvement_tests.rs index 9c0aaf59..d1718ae2 100644 --- a/src/simulator/crates/mc-turn/src/improvement_tests.rs +++ b/src/simulator/crates/mc-turn/src/improvement_tests.rs @@ -34,6 +34,7 @@ fn fort_spec() -> TileImprovementSpec { effects: ImprovementEffects { defense_bonus: 50, concealed_from_surface: false, + terrain_change: None, }, } } @@ -47,6 +48,7 @@ fn tunnel_spec() -> TileImprovementSpec { effects: ImprovementEffects { defense_bonus: 0, concealed_from_surface: true, + terrain_change: None, }, } } @@ -164,3 +166,47 @@ fn serde_round_trip_with_improvements() { assert_eq!(fort.effects.defense_bonus, 50, "effects survive round-trip"); assert!(restored.improvement_at(1, 1).is_none()); } + +fn deforestation_spec() -> TileImprovementSpec { + // Mirrors public/resources/improvements/deforestation.json: a terrain + // transform (enchanted_forest → grassland) with no standing effects. + TileImprovementSpec { + id: "deforestation".to_string(), + hp: 0, + severable: false, + flags: BTreeSet::new(), + effects: ImprovementEffects { + defense_bonus: 0, + concealed_from_surface: false, + terrain_change: Some("grassland".to_string()), + }, + } +} + +#[test] +fn deforestation_flips_grid_biome_to_grassland_and_writes_no_anchor() { + // p2-75 terrain-transform follow-up: completing deforestation on a forest + // tile flips the grid biome to grassland in Rust (Rail 1) and records NO + // standing improvement — a transform is one-shot, not a structure. + let mut gs = GameState::default(); + let mut grid = mc_core::grid::GridState::new(4, 4); + grid.tile_mut(2, 1).expect("tile (2,1) exists").biome_label_id = "enchanted_forest".to_string(); + gs.grid = Some(grid); + + let new_biome = gs.complete_improvement(2, 1, &deforestation_spec()); + + assert_eq!( + new_biome.as_deref(), + Some("grassland"), + "complete_improvement must return the transformed biome label" + ); + assert_eq!( + gs.grid.as_ref().unwrap().tile(2, 1).unwrap().biome_label_id, + "grassland", + "grid biome must be flipped to grassland in Rust" + ); + assert!( + gs.improvement_at(2, 1).is_none(), + "a terrain transform must write no standing improvement anchor" + ); +}