feat: admit signed dynamic world surface module
This commit is contained in:
parent
292889f934
commit
2b1f5d47fd
18 changed files with 584 additions and 14 deletions
|
|
@ -40,6 +40,7 @@ mod web_novel_author;
|
|||
mod web_novel_import;
|
||||
mod web_novel_modules;
|
||||
mod web_novel_workspace;
|
||||
mod world_climate;
|
||||
mod zero_core_numbering;
|
||||
mod zero_point;
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ const MOBILE_SYNC_PACKAGE: &[u8] =
|
|||
const MOBILE_SYNC_SIGNATURE: &str = include_str!(
|
||||
"../../fixtures/module-packages/HLP-MOD-OFFICIAL-MOBILE-SYNC-0001-0.1.0.ghmod.sig"
|
||||
);
|
||||
const DYNAMIC_WORLD_SURFACE_NUMBER: &str = "HLP-MOD-OFFICIAL-DYNAMIC-WORLD-SURFACE-0001";
|
||||
const DYNAMIC_WORLD_SURFACE_PACKAGE: &[u8] = include_bytes!(
|
||||
"../../fixtures/module-packages/HLP-MOD-OFFICIAL-DYNAMIC-WORLD-SURFACE-0001-0.1.0.ghmod"
|
||||
);
|
||||
const DYNAMIC_WORLD_SURFACE_SIGNATURE: &str = include_str!(
|
||||
"../../fixtures/module-packages/HLP-MOD-OFFICIAL-DYNAMIC-WORLD-SURFACE-0001-0.1.0.ghmod.sig"
|
||||
);
|
||||
|
||||
struct BundledModuleSource {
|
||||
module_number: &'static str,
|
||||
|
|
@ -151,6 +158,11 @@ const BUNDLED_MODULES: &[BundledModuleSource] = &[
|
|||
package: MOBILE_SYNC_PACKAGE,
|
||||
signature: MOBILE_SYNC_SIGNATURE,
|
||||
},
|
||||
BundledModuleSource {
|
||||
module_number: DYNAMIC_WORLD_SURFACE_NUMBER,
|
||||
package: DYNAMIC_WORLD_SURFACE_PACKAGE,
|
||||
signature: DYNAMIC_WORLD_SURFACE_SIGNATURE,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -1599,7 +1611,7 @@ mod tests {
|
|||
assert_eq!(package.manifest.adapter, "channel-workbench-v1");
|
||||
assert_eq!(package.manifest.permissions.len(), 4);
|
||||
assert!(is_sha256(&digest));
|
||||
assert_eq!(BUNDLED_MODULES.len(), 10);
|
||||
assert_eq!(BUNDLED_MODULES.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1707,6 +1719,32 @@ mod tests {
|
|||
assert!(is_sha256(&digest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_dynamic_world_surface_is_signed_and_cannot_change_routes() {
|
||||
let (_, package, digest) = read_verified_package_bytes(
|
||||
DYNAMIC_WORLD_SURFACE_PACKAGE,
|
||||
DYNAMIC_WORLD_SURFACE_SIGNATURE,
|
||||
RELEASE_TRUST_RAW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(package.manifest.module_number, DYNAMIC_WORLD_SURFACE_NUMBER);
|
||||
assert_eq!(package.manifest.registration_class, "OFFICIAL_LIGHTHOUSE");
|
||||
assert_eq!(
|
||||
package.manifest.adapter,
|
||||
"dynamic-language-world-surface-v1"
|
||||
);
|
||||
assert_eq!(package.manifest.permissions.len(), 2);
|
||||
assert_eq!(
|
||||
package.payload["adapterConfig"]["routingPermissionOrFactMutation"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
package.payload["adapterConfig"]["legacyTraditionalWorkbench"],
|
||||
"REJECTED_DUPLICATE_SHELL_AND_SIMULATED_STATE"
|
||||
);
|
||||
assert!(is_sha256(&digest));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_package_input_is_rejected_before_signature_processing() {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ fn validate_tree() -> Result<(), String> {
|
|||
|| tree.record_id != "HLP-UNIFIED-NUMBER-TREE-001"
|
||||
|| tree.state != "MACHINE_COMPILED_STARTUP_ENFORCED"
|
||||
|| tree.root_number != "HLP-NUMBER-WORLD-ROOT-001"
|
||||
|| tree.route_count != 168
|
||||
|| tree.route_count != 169
|
||||
|| tree.routes.len() != tree.route_count
|
||||
|| !tree.invariants.number_is_stable_coordinate_not_authority
|
||||
|| !tree.invariants.path_is_unique_navigation
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ mod tests {
|
|||
#[test]
|
||||
fn registry_is_closed_and_contains_every_migrated_command() {
|
||||
let registry = load_registry().unwrap();
|
||||
assert_eq!(registry.operations.len(), 146);
|
||||
assert_eq!(registry.operations.len(), 147);
|
||||
assert!(!registry.runtime.legacy_direct_commands_allowed);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ pub(crate) async fn dispatch(
|
|||
"mobile_sync::get_mobile_sync_snapshot" => {
|
||||
json(crate::mobile_sync::get_mobile_sync_snapshot(app)?)
|
||||
}
|
||||
"world_climate::get_world_climate" => {
|
||||
json(crate::world_climate::get_world_climate(app).await?)
|
||||
}
|
||||
"native_composition::get_native_composition_module_registry" => {
|
||||
json(crate::native_composition::get_native_composition_module_registry())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
//! Signed dynamic language-world climate projection.
|
||||
//!
|
||||
//! The projection may tint the already-approved lake surface. It cannot change
|
||||
//! domain routing, permissions, facts, or the five-theme layout. Real coordinates
|
||||
//! are used only for a bounded public-weather query and are never returned to the
|
||||
//! webview.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-DYNAMIC-WORLD-SURFACE-0001";
|
||||
const ADAPTER: &str = "dynamic-language-world-surface-v1";
|
||||
const SOURCE: &str = "OPEN_METEO_FORECAST_API";
|
||||
const ATTRIBUTION_URL: &str = "https://open-meteo.com/";
|
||||
const CACHE_TTL_MS: u64 = 10 * 60 * 1_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoResponse {
|
||||
current: OpenMeteoCurrent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoCurrent {
|
||||
weather_code: i64,
|
||||
is_day: i64,
|
||||
precipitation: f64,
|
||||
cloud_cover: f64,
|
||||
wind_speed_10m: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorldClimateSnapshot {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
active_domain: &'static str,
|
||||
time_phase: &'static str,
|
||||
weather_kind: &'static str,
|
||||
motion_intensity: &'static str,
|
||||
source: &'static str,
|
||||
source_attribution_url: &'static str,
|
||||
city_exposed: bool,
|
||||
coordinates_exposed: bool,
|
||||
routing_or_permission_changed: bool,
|
||||
observed_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
static CACHE: OnceLock<Mutex<Option<(u64, WorldClimateSnapshot)>>> = OnceLock::new();
|
||||
|
||||
fn require_active(app: &AppHandle) -> Result<(), String> {
|
||||
crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER)
|
||||
}
|
||||
|
||||
pub async fn get_world_climate(app: AppHandle) -> Result<WorldClimateSnapshot, String> {
|
||||
require_active(&app)?;
|
||||
let coordinate = crate::persona_time_authority::get_beijing_time_coordinate()?;
|
||||
let now_ms = coordinate.unix_ms;
|
||||
if let Some(snapshot) = cached(now_ms)? {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
let day_index = coordinate.elapsed_beijing_dates_since_guanghu_epoch;
|
||||
let hour = beijing_hour(&coordinate.beijing_time).unwrap_or(0);
|
||||
let domains = [
|
||||
"FIFTH_DOMAIN",
|
||||
"ZERO_SENSE_DOMAIN",
|
||||
"MAIN_DOMAIN",
|
||||
"BRANCH_DOMAIN",
|
||||
"ZERO_DOMAIN",
|
||||
];
|
||||
let active_domain = domains[day_index.rem_euclid(domains.len() as i64) as usize];
|
||||
let time_phase = phase_for_hour(hour);
|
||||
let (latitude, longitude) = coordinate_for(active_domain, day_index);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("HOLOLAKE_WORLD_CLIMATE_CLIENT_FAILED: {error}"))?;
|
||||
let url = format!(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=weather_code,is_day,precipitation,cloud_cover,wind_speed_10m&timezone=Asia%2FShanghai&forecast_days=1"
|
||||
);
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::USER_AGENT, "HoloLake/0.5.0 world-climate")
|
||||
.send()
|
||||
.await;
|
||||
let snapshot = match response {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
match response.json::<OpenMeteoResponse>().await {
|
||||
Ok(payload) if valid_current(&payload.current) => {
|
||||
live_snapshot(active_domain, time_phase, now_ms, payload.current)
|
||||
}
|
||||
_ => fallback(active_domain, time_phase, now_ms),
|
||||
}
|
||||
}
|
||||
_ => fallback(active_domain, time_phase, now_ms),
|
||||
};
|
||||
store_cache(now_ms, snapshot.clone())?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn cached(now_ms: u64) -> Result<Option<WorldClimateSnapshot>, String> {
|
||||
let guard = CACHE
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.map_err(|_| "HOLOLAKE_WORLD_CLIMATE_CACHE_POISONED".to_string())?;
|
||||
Ok(guard.as_ref().and_then(|(cached_at, snapshot)| {
|
||||
(now_ms.saturating_sub(*cached_at) < CACHE_TTL_MS).then(|| snapshot.clone())
|
||||
}))
|
||||
}
|
||||
|
||||
fn store_cache(now_ms: u64, snapshot: WorldClimateSnapshot) -> Result<(), String> {
|
||||
*CACHE
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.map_err(|_| "HOLOLAKE_WORLD_CLIMATE_CACHE_POISONED".to_string())? =
|
||||
Some((now_ms, snapshot));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn beijing_hour(value: &str) -> Option<u8> {
|
||||
value.split('T').nth(1)?.get(0..2)?.parse().ok()
|
||||
}
|
||||
|
||||
fn phase_for_hour(hour: u8) -> &'static str {
|
||||
match hour {
|
||||
5..=7 => "DAWN",
|
||||
8..=16 => "DAY",
|
||||
17..=19 => "DUSK",
|
||||
_ => "NIGHT",
|
||||
}
|
||||
}
|
||||
|
||||
fn coordinate_for(domain: &str, day_index: i64) -> (f64, f64) {
|
||||
match domain {
|
||||
"FIFTH_DOMAIN" => (34.3416, 108.9398),
|
||||
"ZERO_DOMAIN" => (23.1291, 113.2644),
|
||||
"MAIN_DOMAIN" | "ZERO_SENSE_DOMAIN" => (39.9042, 116.4074),
|
||||
"BRANCH_DOMAIN" => {
|
||||
let roaming = [
|
||||
(31.2304, 121.4737),
|
||||
(30.5728, 104.0668),
|
||||
(30.5928, 114.3055),
|
||||
(30.2741, 120.1551),
|
||||
(32.0603, 118.7969),
|
||||
(29.5630, 106.5516),
|
||||
];
|
||||
roaming[day_index.rem_euclid(roaming.len() as i64) as usize]
|
||||
}
|
||||
_ => (39.9042, 116.4074),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_current(current: &OpenMeteoCurrent) -> bool {
|
||||
current.is_day.abs() <= 1
|
||||
&& current.precipitation.is_finite()
|
||||
&& (0.0..=1_000.0).contains(¤t.precipitation)
|
||||
&& current.cloud_cover.is_finite()
|
||||
&& (0.0..=100.0).contains(¤t.cloud_cover)
|
||||
&& current.wind_speed_10m.is_finite()
|
||||
&& (0.0..=500.0).contains(¤t.wind_speed_10m)
|
||||
}
|
||||
|
||||
fn weather_kind(code: i64, precipitation: f64, cloud_cover: f64) -> &'static str {
|
||||
if matches!(code, 95 | 96 | 99) {
|
||||
"STORM"
|
||||
} else if matches!(code, 71..=77 | 85 | 86) {
|
||||
"SNOW"
|
||||
} else if precipitation > 0.0 || matches!(code, 51..=67 | 80..=82) {
|
||||
"RAIN"
|
||||
} else if matches!(code, 45 | 48) {
|
||||
"FOG"
|
||||
} else if matches!(code, 1..=3) || cloud_cover >= 35.0 {
|
||||
"CLOUD"
|
||||
} else {
|
||||
"CLEAR"
|
||||
}
|
||||
}
|
||||
|
||||
fn live_snapshot(
|
||||
active_domain: &'static str,
|
||||
time_phase: &'static str,
|
||||
observed_at_unix_ms: u64,
|
||||
current: OpenMeteoCurrent,
|
||||
) -> WorldClimateSnapshot {
|
||||
let weather_kind = weather_kind(
|
||||
current.weather_code,
|
||||
current.precipitation,
|
||||
current.cloud_cover,
|
||||
);
|
||||
let motion_intensity = if current.wind_speed_10m >= 35.0 || weather_kind == "STORM" {
|
||||
"ACTIVE"
|
||||
} else if current.wind_speed_10m >= 15.0 || matches!(weather_kind, "RAIN" | "SNOW") {
|
||||
"GENTLE"
|
||||
} else {
|
||||
"CALM"
|
||||
};
|
||||
WorldClimateSnapshot {
|
||||
schema: "hololake.world-climate/v1",
|
||||
state: "VERIFIED_LIVE",
|
||||
active_domain,
|
||||
time_phase: if current.is_day == 0 && time_phase == "DAY" {
|
||||
"NIGHT"
|
||||
} else {
|
||||
time_phase
|
||||
},
|
||||
weather_kind,
|
||||
motion_intensity,
|
||||
source: SOURCE,
|
||||
source_attribution_url: ATTRIBUTION_URL,
|
||||
city_exposed: false,
|
||||
coordinates_exposed: false,
|
||||
routing_or_permission_changed: false,
|
||||
observed_at_unix_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback(
|
||||
active_domain: &'static str,
|
||||
time_phase: &'static str,
|
||||
observed_at_unix_ms: u64,
|
||||
) -> WorldClimateSnapshot {
|
||||
WorldClimateSnapshot {
|
||||
schema: "hololake.world-climate/v1",
|
||||
state: "TIME_ONLY_WEATHER_UNAVAILABLE",
|
||||
active_domain,
|
||||
time_phase,
|
||||
weather_kind: "UNAVAILABLE",
|
||||
motion_intensity: "CALM",
|
||||
source: SOURCE,
|
||||
source_attribution_url: ATTRIBUTION_URL,
|
||||
city_exposed: false,
|
||||
coordinates_exposed: false,
|
||||
routing_or_permission_changed: false,
|
||||
observed_at_unix_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn weather_codes_map_without_exposing_coordinates_or_changing_routes() {
|
||||
assert_eq!(weather_kind(0, 0.0, 3.0), "CLEAR");
|
||||
assert_eq!(weather_kind(61, 0.3, 80.0), "RAIN");
|
||||
assert_eq!(weather_kind(95, 4.0, 100.0), "STORM");
|
||||
let snapshot = fallback("FIFTH_DOMAIN", "NIGHT", 1);
|
||||
assert!(!snapshot.city_exposed);
|
||||
assert!(!snapshot.coordinates_exposed);
|
||||
assert!(!snapshot.routing_or_permission_changed);
|
||||
assert_eq!(snapshot.weather_kind, "UNAVAILABLE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beijing_phase_and_daily_coordinates_are_deterministic() {
|
||||
assert_eq!(beijing_hour("2026-08-19T07:03:02.000+08:00"), Some(7));
|
||||
assert_eq!(phase_for_hour(7), "DAWN");
|
||||
assert_eq!(phase_for_hour(15), "DAY");
|
||||
assert_eq!(phase_for_hour(23), "NIGHT");
|
||||
assert_eq!(coordinate_for("FIFTH_DOMAIN", 1), (34.3416, 108.9398));
|
||||
assert_eq!(coordinate_for("BRANCH_DOMAIN", 0), (31.2304, 121.4737));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_public_weather_values_are_rejected() {
|
||||
assert!(!valid_current(&OpenMeteoCurrent {
|
||||
weather_code: 0,
|
||||
is_day: 1,
|
||||
precipitation: f64::NAN,
|
||||
cloud_cover: 0.0,
|
||||
wind_speed_10m: 0.0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue