feat(@projects/@magic-civilization): ✨ update terrain effects to rust
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2be22f5c57
commit
f773e153ab
7 changed files with 145 additions and 51 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue