feat(@projects/@magic-civilization): update terrain effects to rust

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-08 06:07:44 -07:00
parent 2be22f5c57
commit f773e153ab
7 changed files with 145 additions and 51 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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<String>,
}
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<i32>,
#[serde(default)]
pub concealed_from_surface: Option<bool>,
/// Target biome label for terrain-transform improvements (deforestation /
/// reforestation). Absent for standing improvements.
#[serde(default)]
pub terrain_change: Option<String>,
}
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(),
}
}
}

View file

@ -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);

View file

@ -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<String> {
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.

View file

@ -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"
);
}