From afa7613fd8099f9557a0adb082beb415f25cde87 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 15:31:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8C=8D=20p3-26=20B8=20=E2=80=94=20seismic=20+=20impact=20?= =?UTF-8?q?+=20tsunami=20event=20categories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three geological natural-event categories (parallel agent port, extracted file-only onto current main — the agent worktree forked from a stale base, so a branch merge was unsafe; events.rs is a clean superset verified to compile + test against current HEAD): - seismic — shifts tile elevation over a radius (with falloff). - impact (asteroid) — T1-4 crater (biome/elevation/moisture/quality + local heat + aerosol); T5 extinction (global aerosol injection). Magic/anchor/resource-spawn deferred (Game-3). - tsunami — floods coastal land (moisture/quality/reef_health), skips open water. Each: apply_X + dispatch_X + match arm in process_events + tests. Configs already present (seismic/impact/tsunami.json) + auto-loaded via the headless harness, so they dispatch in process_climate_phase with no extra wiring. mc-climate 61/0; mc-turn builds. 6/12 categories live (wildfire/drought/volcanic/seismic/impact/tsunami). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-climate/src/events.rs | 530 +++++++++++++++++- .../worktrees/agent-a29dd7f314dd44d6d | 1 + .../worktrees/agent-a95ff0acf607fee39 | 1 + 3 files changed, 531 insertions(+), 1 deletion(-) create mode 160000 tooling/claude/dot-claude/worktrees/agent-a29dd7f314dd44d6d create mode 160000 tooling/claude/dot-claude/worktrees/agent-a95ff0acf607fee39 diff --git a/src/simulator/crates/mc-climate/src/events.rs b/src/simulator/crates/mc-climate/src/events.rs index ec41c685..860794c5 100644 --- a/src/simulator/crates/mc-climate/src/events.rs +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -206,7 +206,10 @@ pub fn process_events( "wildfire" => dispatch_wildfire(grid, cfg, tier, turn_seed, channel), "drought" => dispatch_drought(grid, cfg, tier, turn_seed, channel), "volcanic" => dispatch_volcanic(grid, cfg, tier, turn_seed, channel), - // TODO(p3-26 gap 2): impact/seismic/tsunami/plague/pandemic/marine/solar/glacial. + "seismic" => dispatch_seismic(grid, cfg, tier, turn_seed, channel), + "impact" => dispatch_impact(grid, cfg, tier, turn_seed, channel), + "tsunami" => dispatch_tsunami(grid, cfg, tier, turn_seed, channel), + // TODO(p3-26 gap 2): plague/pandemic/marine/solar/glacial. _ => None, }; if let Some(ev) = ev { @@ -413,6 +416,313 @@ fn dispatch_volcanic( }) } +// ── seismic ───────────────────────────────────────────────────────────────── + +/// Apply a seismic event: shift `elevation` by `elevation_delta` (clamped to [0,1]) +/// over a hex disk of `radius` around `center`, with a linear distance falloff so +/// the epicentre moves most. Returns the count of tiles affected. Mirrors GDScript +/// `process_seismic` (building/unit/wall damage is recorded in the event log and +/// processed by city.gd — not applied here, same as the live handler). +pub fn apply_seismic( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + radius: i32, + elevation_delta: f32, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut affected = 0; + for (q, r) in hex_spiral(cq, cr, radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + let dist = { + let dq = (q - cq).abs(); + let dr = (r - cr).abs(); + let ds = (q + r - cq - cr).abs(); + ((dq + dr + ds) / 2) as f32 + }; + let falloff = 1.0 - dist / (radius + 1) as f32; + t.elevation = (t.elevation + elevation_delta * falloff).clamp(0.0, 1.0); + affected += 1; + } + } + affected +} + +/// Resolve the seismic tier config + pick a non-water center, then quake. +fn dispatch_seismic( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let radius = tc.get("radius").and_then(|v| v.as_i64()).unwrap_or(2) as i32; + let elevation_delta = tc.get("elevation_delta").and_then(|v| v.as_f64()).unwrap_or(0.01) as f32; + let center = pick_matching_tile(grid, channel, turn_seed, |b| !has_tag(b, BiomeTag::IsWater))?; + let affected = apply_seismic(grid, center, radius, elevation_delta); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "seismic".to_string(), + tier, + center, + affected, + }) +} + +// ── impact ─────────────────────────────────────────────────────────────────── + +/// Apply an asteroid impact. Handles two distinct code paths that mirror GDScript +/// `process_impact`: +/// +/// **T5 extinction path** (`crater_radius` present, no `elevation_delta`): +/// - Crater disk: non-water tiles → `"desert"`, elevation −`elevation_loss`, moisture 0, quality 1. +/// - Global sulfate aerosol injection (`aerosol_strength` on every tile). +/// - Biome collapse: living terrain → quality loss; jungle → grassland; swamp → desert. +/// - Impact site → `"mana_node"` (game-3 anchor skipped per contract, same as volcanic). +/// +/// **T1-T4 standard path** (`elevation_delta` present): +/// - If `elevation_delta > 0`: center → `crater_terrain`, elevation raised, quality 3. +/// - If `elevation_delta ≤ 0`: center → `"lake"` (low elevation) or `"desert"`, elevation lowered, quality 1. +/// - Heat pulse: `magic_heat_delta` += `heat_delta` over `heat_radius`. +/// - Aerosol injection in `aerosol_radius` around center. +/// +/// Returns tiles affected (crater + scorch disk for T5, 1 for T1-T4). +/// Resource/wonder-anchor bits from GDScript are Game-3 deferred, matching `apply_volcanic`. +pub fn apply_impact( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + tier: usize, + crater_radius: i32, + elevation_loss: f32, + aerosol_strength_global: f32, + aerosol_strength_local: f32, + aerosol_radius: i32, + elevation_delta: f32, + crater_terrain: &str, + heat_radius: i32, + heat_delta: f32, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + + if tier == 5 && crater_radius > 0 { + // Extinction-level path + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut affected = 0; + // Phase 1: crater vaporisation + for (q, r) in hex_spiral(cq, cr, crater_radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + if !has_tag(&t.biome_label_id, BiomeTag::IsWater) { + t.biome_label_id = "desert".to_string(); + t.elevation = (t.elevation - elevation_loss).max(0.0); + t.moisture = 0.0; + t.quality = 1; + affected += 1; + } + } + } + // Phase 2: global aerosol + if aerosol_strength_global > 0.0 { + for t in &mut grid.tiles { + t.sulfate_aerosol += aerosol_strength_global; + } + } + // Phase 3: biome collapse (living terrain quality loss / downgrade) + let living: &[&str] = &[ + "forest", "jungle", "enchanted_forest", "swamp", "grassland", "plains", "boreal_forest", + ]; + let biome_kill_quality: i32 = 2; // GDScript hardcodes biome_kill_quality_loss=2 from tier_cfg default + for t in &mut grid.tiles { + if living.iter().any(|b| *b == t.biome_label_id.as_str()) { + t.quality = (t.quality - biome_kill_quality).max(1); + match t.biome_label_id.as_str() { + "jungle" => { + t.biome_label_id = "grassland".to_string(); + t.moisture = (t.moisture - 0.15).max(0.0); + } + "enchanted_forest" => { + t.biome_label_id = "forest".to_string(); + } + "swamp" => { + t.biome_label_id = "desert".to_string(); + t.moisture = 0.0; + } + _ => {} + } + } + } + // Impact site mana node (anchor/resource Game-3 deferred, matching volcanic) + if let Some(t) = grid.tile_mut(center.0, center.1) { + t.biome_label_id = "mana_node".to_string(); + t.quality = 5; + } + return affected; + } + + // T1-T4 standard path + let (cq, cr) = offset_to_axial(center.0, center.1); + + // Center crater + if let Some(t) = grid.tile_mut(center.0, center.1) { + if elevation_delta > 0.0 { + t.biome_label_id = crater_terrain.to_string(); + t.elevation = (t.elevation + elevation_delta).min(1.0); + t.moisture = (t.moisture - 0.1).max(0.0); + t.quality = 3; + } else { + t.biome_label_id = if t.elevation < 0.15 { "lake" } else { "desert" }.to_string(); + t.elevation = (t.elevation + elevation_delta).max(0.0); + t.quality = 1; + } + } + + // Heat pulse + if heat_radius > 0 && heat_delta > 0.0 { + for (q, r) in hex_spiral(cq, cr, heat_radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + t.magic_heat_delta += heat_delta; + } + } + } + + // Local aerosol injection + if aerosol_strength_local > 0.0 && aerosol_radius > 0 { + for (q, r) in hex_spiral(cq, cr, aerosol_radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + t.sulfate_aerosol += aerosol_strength_local; + } + } + } + + 1 +} + +/// Resolve the impact tier config + pick any tile (including water, per GDScript which +/// uses `rng.randi_range(0, w-1)` — not a land-only pick), then apply. +fn dispatch_impact( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + + // GDScript picks any tile (randi_range over full grid), including water/ocean. + // We use pick_matching_tile with an always-true predicate for internal determinism. + let center = pick_matching_tile(grid, channel, turn_seed, |_b| true)?; + + let crater_radius = tc.get("crater_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let elevation_loss = tc.get("elevation_loss").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let aerosol_strength = tc.get("aerosol_strength").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let aerosol_radius = tc.get("aerosol_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let elevation_delta = tc.get("elevation_delta").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let crater_terrain = tc.get("crater_terrain").and_then(|v| v.as_str()).unwrap_or("mountains"); + let heat_radius = tc.get("heat_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let heat_delta = tc.get("heat_delta").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + + // For T5, aerosol is global (aerosol_global: true in JSON); for T1-T4 it's local. + let is_global_aerosol = tier == 5 && crater_radius > 0; + let (global_aero, local_aero) = if is_global_aerosol { + (aerosol_strength, 0.0_f32) + } else { + (0.0_f32, aerosol_strength) + }; + + let affected = apply_impact( + grid, center, tier, + crater_radius, elevation_loss, + global_aero, local_aero, aerosol_radius, + elevation_delta, crater_terrain, + heat_radius, heat_delta, + ); + Some(FiredEvent { + category: "impact".to_string(), + tier, + center, + affected, + }) +} + +// ── tsunami ────────────────────────────────────────────────────────────────── + +/// Apply a tsunami: floods non-water tiles within `inland_reach` of a coast center. +/// Each non-water tile: moisture += `moisture_gain` (clamped to 1.0), quality set to +/// `quality_reset` (if > 0, clamped to max(1, quality_reset)). Water tiles with +/// `reef_destruction=true` lose 0.5 reef_health. Returns flooded tile count. +/// Mirrors GDScript `process_tsunami` (building/wall/unit damage is event-log-only). +pub fn apply_tsunami( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + inland_reach: i32, + moisture_gain: f32, + quality_reset: i32, + reef_destruction: bool, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut flooded = 0; + for (q, r) in hex_spiral(cq, cr, inland_reach) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + if has_tag(&t.biome_label_id, BiomeTag::IsWater) { + if reef_destruction { + t.reef_health = (t.reef_health - 0.5).max(0.0); + } + } else { + if moisture_gain > 0.0 { + t.moisture = (t.moisture + moisture_gain).min(1.0); + } + if quality_reset > 0 { + t.quality = quality_reset.max(1); + } + flooded += 1; + } + } + } + flooded +} + +/// Resolve the tsunami tier config + pick a coast center, then flood inland. +/// GDScript requires the center tile to have `is_coast` tag; non-coast picks return early. +fn dispatch_tsunami( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let inland_reach = tc.get("inland_reach").and_then(|v| v.as_i64()).unwrap_or(1) as i32; + let moisture_gain = tc.get("moisture_gain").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let quality_reset = tc.get("quality_reset").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let reef_destruction = tc.get("reef_destruction").and_then(|v| v.as_bool()).unwrap_or(false); + + // Must land on a coast tile (GDScript: `if not BiomeRegistry.has_tag(center, "is_coast"): return`). + let center = pick_matching_tile(grid, channel, turn_seed, |b| has_tag(b, BiomeTag::IsCoast))?; + + let affected = apply_tsunami(grid, center, inland_reach, moisture_gain, quality_reset, reef_destruction); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "tsunami".to_string(), + tier, + center, + affected, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -605,4 +915,222 @@ mod tests { assert!(!cfgs.contains_key("cross_triggers")); assert!(!cfgs.contains_key("events")); } + + #[test] + fn apply_seismic_shifts_elevation_with_falloff() { + use mc_core::grid::GridState; + let mut grid = GridState::new(12, 12); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.5; + } + let affected = apply_seismic(&mut grid, (5, 5), 2, 0.1); + assert!(affected >= 1, "should affect tiles in radius"); + // Center tile (dist=0) gets full delta (falloff = 1 - 0/3 = 1.0). + let center = grid.tile(5, 5).unwrap(); + assert!( + (center.elevation - 0.6).abs() < 1e-4, + "center elevation should be 0.5 + 0.1*1.0 = 0.6, got {}", + center.elevation + ); + // A tile at distance=2 gets partial shift (falloff = 1 - 2/3 ≈ 0.333). + // We just verify the total shift is less than at center. + // (Exact neighbor depends on hex offset math; just verify bounds.) + assert!( + grid.tiles.iter().any(|t| t.elevation > 0.5 && t.elevation < 0.6), + "some tile should have partial elevation shift from falloff" + ); + // Elevation is clamped to [0, 1]. + let mut grid2 = GridState::new(6, 6); + for t in &mut grid2.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.99; + } + apply_seismic(&mut grid2, (3, 3), 1, 0.5); + assert!( + grid2.tiles.iter().all(|t| t.elevation <= 1.0), + "elevation must not exceed 1.0" + ); + } + + #[test] + fn apply_seismic_deterministic() { + use mc_core::grid::GridState; + let make = || { + let mut g = GridState::new(10, 10); + for t in &mut g.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.5; + } + g + }; + let mut g1 = make(); + let mut g2 = make(); + apply_seismic(&mut g1, (4, 4), 2, 0.05); + apply_seismic(&mut g2, (4, 4), 2, 0.05); + let elevations1: Vec = g1.tiles.iter().map(|t| t.elevation).collect(); + let elevations2: Vec = g2.tiles.iter().map(|t| t.elevation).collect(); + assert_eq!(elevations1, elevations2, "seismic must be deterministic"); + } + + #[test] + fn apply_impact_t1_t4_modifies_center_and_heat() { + use mc_core::grid::GridState; + let mut grid = GridState::new(12, 12); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.5; + t.magic_heat_delta = 0.0; + } + // T1: elevation_delta < 0 → center becomes desert, elevation falls. + let affected = apply_impact( + &mut grid, (5, 5), + /*tier=*/1, + /*crater_radius=*/0, /*elevation_loss=*/0.0, + /*global_aero=*/0.0, /*local_aero=*/0.0, /*aerosol_radius=*/0, + /*elevation_delta=*/-0.1, + /*crater_terrain=*/"mountains", + /*heat_radius=*/1, /*heat_delta=*/0.02, + ); + assert_eq!(affected, 1); + let center = grid.tile(5, 5).unwrap(); + assert_eq!(center.biome_label_id, "desert", "low elev_delta → desert"); + assert!((center.elevation - 0.4).abs() < 1e-4, "elevation lowered"); + assert_eq!(center.quality, 1); + // heat pulse injected within heat_radius + assert!( + grid.tiles.iter().any(|t| t.magic_heat_delta > 0.0), + "heat pulse must propagate" + ); + } + + #[test] + fn apply_impact_t5_extinction_craters_and_injects_global_aerosol() { + use mc_core::grid::GridState; + let mut grid = GridState::new(16, 16); + for t in &mut grid.tiles { + t.biome_label_id = "jungle".into(); + t.elevation = 0.5; + t.moisture = 0.8; + t.quality = 3; + t.sulfate_aerosol = 0.0; + } + let affected = apply_impact( + &mut grid, (7, 7), + /*tier=*/5, + /*crater_radius=*/2, /*elevation_loss=*/0.3, + /*global_aero=*/1.0, /*local_aero=*/0.0, /*aerosol_radius=*/0, + /*elevation_delta=*/0.0, + /*crater_terrain=*/"mountains", + /*heat_radius=*/0, /*heat_delta=*/0.0, + ); + assert!(affected >= 1, "crater must affect some land"); + // Global aerosol on every tile + assert!( + grid.tiles.iter().all(|t| t.sulfate_aerosol >= 1.0), + "global aerosol must reach every tile" + ); + // Biome collapse: jungle → grassland + assert!( + grid.tiles.iter().any(|t| t.biome_label_id == "grassland"), + "jungle must collapse to grassland" + ); + // Impact site → mana_node + assert_eq!( + grid.tile(7, 7).unwrap().biome_label_id, + "mana_node", + "T5 impact site → mana_node" + ); + } + + #[test] + fn apply_tsunami_floods_land_skips_water() { + use mc_core::grid::GridState; + let mut grid = GridState::new(12, 12); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + t.moisture = 0.3; + t.quality = 3; + t.reef_health = 1.0; + } + // Place coast center + an ocean tile in radius + if let Some(t) = grid.tile_mut(5, 5) { + t.biome_label_id = "coast".into(); + } + if let Some(t) = grid.tile_mut(6, 5) { + t.biome_label_id = "ocean".into(); + t.reef_health = 1.0; + } + let flooded = apply_tsunami(&mut grid, (5, 5), 2, 0.2, 1, true); + assert!(flooded >= 1, "should flood some land tiles"); + // Land tile in radius should have boosted moisture + let land = grid.tile(4, 5).unwrap(); + assert!(land.moisture > 0.3, "inland tile moisture boosted"); + // quality_reset = 1 applied to land tiles + assert_eq!(land.quality, 1, "quality reset to 1"); + // water tile reef_health decreased (reef_destruction=true) + let ocean = grid.tile(6, 5).unwrap(); + assert!((ocean.reef_health - 0.5).abs() < 1e-4, "reef health reduced by 0.5"); + } + + #[test] + fn dispatch_seismic_fires_via_process_events() { + use mc_core::grid::GridState; + let mut grid = GridState::new(10, 10); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.5; + } + let mut configs = std::collections::BTreeMap::new(); + configs.insert( + "seismic".to_string(), + EventCategoryConfig { + base_frequency: 1.0, + severity_weights: vec![100], + raw: serde_json::json!({ + "tiers": { "1": { "radius": 2, "elevation_delta": 0.05 } } + }), + }, + ); + let fired = process_events(&mut grid, &configs, 3, 42, 10); + assert_eq!(fired.len(), 1); + assert_eq!(fired[0].category, "seismic"); + assert!(grid.tiles.iter().any(|t| (t.elevation - 0.5).abs() > 1e-5), + "elevation must have changed after seismic"); + } + + #[test] + fn dispatch_tsunami_requires_coast_center() { + use mc_core::grid::GridState; + // Grid with no coast tiles → tsunami should not fire (None from pick_matching_tile). + let mut grid = GridState::new(8, 8); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + } + let mut configs = std::collections::BTreeMap::new(); + configs.insert( + "tsunami".to_string(), + EventCategoryConfig { + base_frequency: 1.0, + severity_weights: vec![100], + raw: serde_json::json!({ + "tiers": { "1": { "inland_reach": 1 } } + }), + }, + ); + let fired = process_events(&mut grid, &configs, 1, 1, 10); + assert!(fired.is_empty(), "tsunami must not fire without a coast tile"); + + // Grid with at least one coast tile + surrounding land → fires. + let mut grid2 = GridState::new(8, 8); + for t in &mut grid2.tiles { + t.biome_label_id = "grassland".into(); + } + if let Some(t) = grid2.tile_mut(4, 4) { + t.biome_label_id = "coast".into(); + } + let fired2 = process_events(&mut grid2, &configs, 1, 1, 10); + assert_eq!(fired2.len(), 1, "tsunami should fire when coast tile exists"); + assert_eq!(fired2[0].category, "tsunami"); + } } diff --git a/tooling/claude/dot-claude/worktrees/agent-a29dd7f314dd44d6d b/tooling/claude/dot-claude/worktrees/agent-a29dd7f314dd44d6d new file mode 160000 index 00000000..94affd5a --- /dev/null +++ b/tooling/claude/dot-claude/worktrees/agent-a29dd7f314dd44d6d @@ -0,0 +1 @@ +Subproject commit 94affd5ad32e852882e487100a9fb0dae3da7b84 diff --git a/tooling/claude/dot-claude/worktrees/agent-a95ff0acf607fee39 b/tooling/claude/dot-claude/worktrees/agent-a95ff0acf607fee39 new file mode 160000 index 00000000..474160ef --- /dev/null +++ b/tooling/claude/dot-claude/worktrees/agent-a95ff0acf607fee39 @@ -0,0 +1 @@ +Subproject commit 474160ef178b5c526b0f3526fb0a9485046ad624