//! Official Yostar JP resource URL rules. //! //! These builders mirror the client-side patch strategies recovered from and //! checked against the JP client endpoints: //! - server info is hosted under `yostar-serverinfo.bluearchiveyostar.com` //! - resource patches are hosted under `prod-clientpatch.bluearchiveyostar.com` //! - Windows AssetBundles are downloaded as patch-pack zip files, not as the //! naked `.bundle` names present in `catalog_Remote.json` //! - Android uses its own patch-pack directory use serde::{Deserialize, Serialize}; /// Official JP server-info host. pub const SERVER_INFO_HOST: &str = "yostar-serverinfo.bluearchiveyostar.com"; /// Official JP client-patch host. pub const CLIENT_PATCH_HOST: &str = "prod-clientpatch.bluearchiveyostar.com"; const SERVER_INFO_ROOT: &str = "https://yostar-serverinfo.bluearchiveyostar.com"; const CLIENT_PATCH_ROOT: &str = "https://prod-clientpatch.bluearchiveyostar.com"; /// Official JP server-info payload. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "PascalCase")] pub struct YostarJpServerInfo { #[serde(default)] connection_groups: Vec, } impl YostarJpServerInfo { /// Parses a server-info JSON document. pub fn from_slice(data: &[u8]) -> Result { serde_json::from_slice(data) } /// Parses a server-info JSON string. pub fn from_json(data: &str) -> Result { serde_json::from_str(data) } /// Returns all connection groups in their server-provided order. pub fn connection_groups(&self) -> &[YostarJpConnectionGroup] { &self.connection_groups } /// Selects a connection group using the same name and version override /// rule as the JP client. pub fn connection_group( &self, name: &str, app_version: &str, ) -> Result { self.connection_groups .iter() .find(|group| group.name.as_deref() == Some(name)) .map(|group| group.merged_for_version(app_version)) .ok_or_else(|| format!("Server-info connection group not found: {name}")) } /// Selects and validates the official JP resource root for a connection /// group and app version. pub fn resource_root( &self, name: &str, app_version: &str, ) -> Result { self.connection_group(name, app_version)?.resource_root() } /// Builds the official JP resource discovery seed plan for the selected /// connection group, app version, and platforms. pub fn discovery_plan( &self, name: &str, app_version: &str, platforms: &[PatchPlatform], ) -> Result { let group = self.connection_group(name, app_version)?; let root = group.resource_root()?; let endpoints = root.seed_endpoints(platforms)?; Ok(YostarJpResourceDiscoveryPlan { connection_group_name: group.name().unwrap_or(name).to_string(), app_version: app_version.to_string(), bundle_version: group.bundle_version().map(ToOwned::to_owned), addressables_root: root.addressables_root(), endpoints, }) } /// Builds a synchronization snapshot for comparing local and official /// state. pub fn sync_snapshot( &self, name: &str, app_version: &str, platforms: &[PatchPlatform], ) -> Result { let plan = self.discovery_plan(name, app_version, platforms)?; Ok(YostarJpSyncSnapshot { connection_group_name: plan.connection_group_name, app_version: plan.app_version, bundle_version: plan.bundle_version, addressables_root: plan.addressables_root, endpoints: plan.endpoints, }) } } /// Official JP server-info connection group. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "PascalCase")] pub struct YostarJpConnectionGroup { #[serde(default)] name: Option, #[serde(default)] management_data_url: Option, #[serde(default)] addressables_catalog_url_root: Option, #[serde(default)] is_production_addressables: Option, #[serde(default)] api_url: Option, #[serde(default)] gateway_url: Option, #[serde(default)] kibana_log_url: Option, #[serde(default)] chatting_server_address: Option, #[serde(default)] prohibited_word_black_list_uri: Option, #[serde(default)] prohibited_word_white_list_uri: Option, #[serde(default)] customer_service_url: Option, #[serde(default)] override_connection_groups: Vec, #[serde(default)] bundle_version: Option, #[serde(default)] is_live_published: bool, } impl YostarJpConnectionGroup { /// Returns the connection group name. pub fn name(&self) -> Option<&str> { self.name.as_deref() } /// Returns the selected `AddressablesCatalogUrlRoot`, if present. pub fn addressables_catalog_url_root(&self) -> Option<&str> { self.addressables_catalog_url_root.as_deref() } /// Returns the selected bundle version, if present. pub fn bundle_version(&self) -> Option<&str> { self.bundle_version.as_deref() } /// Returns the production-addressables flag, if present. pub fn is_production_addressables(&self) -> Option { self.is_production_addressables } /// Returns whether this group is marked as live-published. pub fn is_live_published(&self) -> bool { self.is_live_published } /// Returns a group with the first matching version override applied. /// /// The JP client tests overrides with `app_version.StartsWith(Name)` and /// applies only non-empty string fields. `BundleVersion` and /// `IsLivePublished` remain inherited from the base group. pub fn merged_for_version(&self, app_version: &str) -> Self { let mut merged = self.clone(); merged.override_connection_groups.clear(); let Some(overriding) = self.override_connection_groups.iter().find(|group| { group .name .as_deref() .is_some_and(|name| app_version.starts_with(name)) }) else { return merged; }; override_non_empty( &mut merged.management_data_url, &overriding.management_data_url, ); override_non_empty( &mut merged.addressables_catalog_url_root, &overriding.addressables_catalog_url_root, ); if overriding.is_production_addressables.is_some() { merged.is_production_addressables = overriding.is_production_addressables; } override_non_empty(&mut merged.api_url, &overriding.api_url); override_non_empty(&mut merged.gateway_url, &overriding.gateway_url); override_non_empty(&mut merged.kibana_log_url, &overriding.kibana_log_url); override_non_empty( &mut merged.chatting_server_address, &overriding.chatting_server_address, ); override_non_empty( &mut merged.prohibited_word_black_list_uri, &overriding.prohibited_word_black_list_uri, ); override_non_empty( &mut merged.prohibited_word_white_list_uri, &overriding.prohibited_word_white_list_uri, ); override_non_empty( &mut merged.customer_service_url, &overriding.customer_service_url, ); merged } /// Validates and returns the official resource root for this group. pub fn resource_root(&self) -> Result { let root = self .addressables_catalog_url_root() .ok_or_else(|| "Server-info group has no AddressablesCatalogUrlRoot".to_string())?; YostarJpResourceRoot::from_addressables_root(root) } } /// Patch platform selected by the game client. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum PatchPlatform { /// Standalone Windows client. Windows, /// Android client. Android, } impl PatchPlatform { /// Returns a stable display label for the platform. pub fn as_str(self) -> &'static str { match self { Self::Windows => "Windows", Self::Android => "Android", } } fn catalog_zip_name(self) -> Result<&'static str, String> { match self { Self::Windows => Ok("catalog_StandaloneWindows64.zip"), Self::Android => Ok("catalog_Android.zip"), } } fn patch_pack_dir(self) -> Result<&'static str, String> { match self { Self::Windows => Ok("Windows_PatchPack"), Self::Android => Ok("Android_PatchPack"), } } fn media_dir(self) -> Result<&'static str, String> { match self { Self::Windows => Ok("MediaResources-Windows"), Self::Android => Ok("MediaResources"), } } } /// Platforms that have verified official JP patch-path coverage. pub fn verified_official_platforms() -> Vec { vec![PatchPlatform::Windows, PatchPlatform::Android] } /// Kind of official JP resource endpoint discovered from a resource root. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum YostarJpResourceEndpointKind { /// `TableBundles/TableCatalog.bytes`. TableCatalog, /// `TableBundles/TableCatalog.hash`. TableCatalogHash, /// Platform Addressables catalog zip. AddressablesCatalog, /// Platform Addressables catalog hash. AddressablesCatalogHash, /// Platform `BundlePackingInfo.bytes`. BundlePackingInfo, /// Platform `BundlePackingInfo.hash`. BundlePackingInfoHash, /// Platform media catalog bytes. MediaCatalog, /// Platform media catalog hash. MediaCatalogHash, } /// One official JP resource URL seed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct YostarJpResourceEndpoint { /// Endpoint kind. pub kind: YostarJpResourceEndpointKind, /// Platform when the endpoint is platform-specific. pub platform: Option, /// Official URL. pub url: String, } /// Official JP resource discovery seed plan. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct YostarJpResourceDiscoveryPlan { /// Selected server-info connection group name. pub connection_group_name: String, /// App version used for override selection. pub app_version: String, /// Server-info bundle version inherited from the selected group. pub bundle_version: Option, /// Selected official `AddressablesCatalogUrlRoot`. pub addressables_root: String, /// Seed URLs that should be fetched before parsing downstream catalogs. pub endpoints: Vec, } /// Snapshot of the official JP resource state observed at a point in time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct YostarJpSyncSnapshot { /// Selected server-info connection group name. pub connection_group_name: String, /// App version used for selection. pub app_version: String, /// Server-info bundle version inherited from the selected group. pub bundle_version: Option, /// Selected official `AddressablesCatalogUrlRoot`. pub addressables_root: String, /// Seed URLs that should be checked or fetched. pub endpoints: Vec, } impl YostarJpSyncSnapshot { /// Returns the official resource root token, if available. pub fn root_token(&self) -> Option<&str> { self.addressables_root .strip_prefix(CLIENT_PATCH_ROOT) .and_then(|rest| rest.strip_prefix('/')) } /// Compares this snapshot with a previously stored snapshot. pub fn diff(&self, previous: Option<&YostarJpSyncSnapshot>) -> YostarJpSyncDelta { let Some(previous) = previous else { return YostarJpSyncDelta::new(true); }; let mut delta = YostarJpSyncDelta::default(); delta.connection_group_changed = self.connection_group_name != previous.connection_group_name; delta.app_version_changed = self.app_version != previous.app_version; delta.bundle_version_changed = self.bundle_version != previous.bundle_version; delta.addressables_root_changed = self.addressables_root != previous.addressables_root; delta.root_token_changed = self.root_token() != previous.root_token(); delta.endpoint_changes = diff_endpoints(&self.endpoints, &previous.endpoints); delta.needs_download = delta.addressables_root_changed || delta.root_token_changed || delta.bundle_version_changed || !delta.endpoint_changes.is_empty(); delta } } /// Delta between an official snapshot and a previously stored snapshot. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct YostarJpSyncDelta { /// Whether this is the first observation. pub is_initial: bool, /// Whether the connection group name changed. pub connection_group_changed: bool, /// Whether the app version changed. pub app_version_changed: bool, /// Whether the bundle version changed. pub bundle_version_changed: bool, /// Whether the addressables root URL changed. pub addressables_root_changed: bool, /// Whether the root token segment changed. pub root_token_changed: bool, /// Endpoint additions/removals/updates. pub endpoint_changes: Vec, /// Whether downstream download should be triggered. pub needs_download: bool, } impl YostarJpSyncDelta { fn new(is_initial: bool) -> Self { Self { is_initial, needs_download: is_initial, ..Self::default() } } /// Returns true if the snapshot is materially different. pub fn has_changes(&self) -> bool { self.is_initial || self.connection_group_changed || self.app_version_changed || self.bundle_version_changed || self.addressables_root_changed || self.root_token_changed || !self.endpoint_changes.is_empty() } } /// Individual endpoint difference. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum YostarJpResourceEndpointChange { /// Endpoint newly observed. Added(YostarJpResourceEndpoint), /// Endpoint no longer present. Removed(YostarJpResourceEndpoint), /// Endpoint content changed. Updated { /// Previous value. previous: YostarJpResourceEndpoint, /// Current value. current: YostarJpResourceEndpoint, }, } fn diff_endpoints( current: &[YostarJpResourceEndpoint], previous: &[YostarJpResourceEndpoint], ) -> Vec { let mut changes = Vec::new(); let mut previous_index = std::collections::BTreeMap::new(); let mut current_index = std::collections::BTreeMap::new(); for endpoint in previous { previous_index.insert(endpoint_key(endpoint), endpoint.clone()); } for endpoint in current { current_index.insert(endpoint_key(endpoint), endpoint.clone()); } for (key, current_endpoint) in ¤t_index { match previous_index.get(key) { None => changes.push(YostarJpResourceEndpointChange::Added( current_endpoint.clone(), )), Some(previous_endpoint) if previous_endpoint != current_endpoint => { changes.push(YostarJpResourceEndpointChange::Updated { previous: previous_endpoint.clone(), current: current_endpoint.clone(), }) } _ => {} } } for (key, previous_endpoint) in &previous_index { if !current_index.contains_key(key) { changes.push(YostarJpResourceEndpointChange::Removed( previous_endpoint.clone(), )); } } changes.sort_by(|left, right| endpoint_change_key(left).cmp(&endpoint_change_key(right))); changes } fn endpoint_key(endpoint: &YostarJpResourceEndpoint) -> (&'static str, Option) { ( match endpoint.kind { YostarJpResourceEndpointKind::TableCatalog => "table_catalog", YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash", YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog", YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash", YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info", YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash", YostarJpResourceEndpointKind::MediaCatalog => "media_catalog", YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash", }, endpoint.platform, ) } fn endpoint_change_key( change: &YostarJpResourceEndpointChange, ) -> (u8, &'static str, Option) { match change { YostarJpResourceEndpointChange::Added(endpoint) => { (0, endpoint_key(endpoint).0, endpoint.platform) } YostarJpResourceEndpointChange::Removed(endpoint) => { (1, endpoint_key(endpoint).0, endpoint.platform) } YostarJpResourceEndpointChange::Updated { current, .. } => { (2, endpoint_key(current).0, current.platform) } } } /// Official JP resource URL builder for one `AddressablesCatalogUrlRoot`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct YostarJpResourceRoot { root_token: String, } impl YostarJpResourceRoot { /// Creates a builder from a server-info `AddressablesCatalogUrlRoot` value. /// /// The value must use the official JP client-patch host. Mirror hosts are /// rejected here so callers cannot accidentally treat localized mirrors as /// official resources. pub fn from_addressables_root(root: &str) -> Result { let prefix = format!("{}/", CLIENT_PATCH_ROOT); let root_token = root .strip_prefix(&prefix) .ok_or_else(|| format!("Addressables root is not official JP client patch: {root}"))?; Self::from_root_token(root_token) } /// Creates a builder from the root token in server-info override data. /// /// Example token shape: `r93_dctuo3tcd029wwxnvb55`. pub fn from_root_token(root_token: &str) -> Result { validate_relative_segment(root_token, "root token")?; Ok(Self { root_token: root_token.to_string(), }) } /// Returns the official `AddressablesCatalogUrlRoot`. pub fn addressables_root(&self) -> String { format!("{}/{}", CLIENT_PATCH_ROOT, self.root_token) } /// Returns the official Addressables catalog zip URL. pub fn addressables_catalog_zip(&self, platform: PatchPlatform) -> Result { Ok(format!( "{}/{}/{}/{}", CLIENT_PATCH_ROOT, self.root_token, platform.patch_pack_dir()?, platform.catalog_zip_name()? )) } /// Returns the official Addressables catalog hash URL. pub fn addressables_catalog_hash(&self, platform: PatchPlatform) -> Result { Ok(self .addressables_catalog_zip(platform)? .trim_end_matches(".zip") .to_string() + ".hash") } /// Returns the official bundle patch-pack catalog URL. pub fn bundle_packing_info(&self, platform: PatchPlatform) -> Result { Ok(format!( "{}/{}/{}/BundlePackingInfo.bytes", CLIENT_PATCH_ROOT, self.root_token, platform.patch_pack_dir()? )) } /// Returns the official bundle patch-pack catalog hash URL. pub fn bundle_packing_hash(&self, platform: PatchPlatform) -> Result { Ok(format!( "{}/{}/{}/BundlePackingInfo.hash", CLIENT_PATCH_ROOT, self.root_token, platform.patch_pack_dir()? )) } /// Returns an official bundle patch-pack zip URL. /// /// The argument is the `BundlePatchPack.PackName` field from /// `BundlePackingInfo.bytes`, for example `FullPatch_000.zip`. pub fn bundle_patch_pack( &self, platform: PatchPlatform, pack_name: &str, ) -> Result { validate_relative_path(pack_name, "bundle patch pack")?; Ok(format!( "{}/{}/{}/{}", CLIENT_PATCH_ROOT, self.root_token, platform.patch_pack_dir()?, normalize_relative_path(pack_name) )) } /// Returns the official table catalog URL. pub fn table_catalog(&self) -> String { format!( "{}/{}/TableBundles/TableCatalog.bytes", CLIENT_PATCH_ROOT, self.root_token ) } /// Returns the official table catalog hash URL. pub fn table_catalog_hash(&self) -> String { format!( "{}/{}/TableBundles/TableCatalog.hash", CLIENT_PATCH_ROOT, self.root_token ) } /// Returns an official table bundle URL. /// /// The argument is the `TableBundle.Name` field from `TableCatalog.bytes`, /// for example `ExcelDB.db` or `Excel.zip`. pub fn table_bundle(&self, name: &str) -> Result { validate_relative_path(name, "table bundle")?; Ok(format!( "{}/{}/TableBundles/{}", CLIENT_PATCH_ROOT, self.root_token, normalize_relative_path(name) )) } /// Returns the official media catalog URL. pub fn media_catalog(&self, platform: PatchPlatform) -> Result { Ok(format!( "{}/{}/{}/Catalog/MediaCatalog.bytes", CLIENT_PATCH_ROOT, self.root_token, platform.media_dir()? )) } /// Returns the official media catalog hash URL. pub fn media_catalog_hash(&self, platform: PatchPlatform) -> Result { Ok(format!( "{}/{}/{}/Catalog/MediaCatalog.hash", CLIENT_PATCH_ROOT, self.root_token, platform.media_dir()? )) } /// Returns an official media archive URL. /// /// The argument is the `Media.FileName` field from `MediaCatalog.bytes`, /// for example `JP_Airi.zip`. pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result { validate_relative_path(file_name, "media file")?; Ok(format!( "{}/{}/{}/{}", CLIENT_PATCH_ROOT, self.root_token, platform.media_dir()?, normalize_relative_path(file_name) )) } /// Returns the official seed URLs needed to discover concrete resource /// files for the requested platforms. /// /// Table bundle seeds are emitted once because they are not platform /// specific. Addressables, bundle patch-pack, and media seeds are emitted /// per platform. pub fn seed_endpoints( &self, platforms: &[PatchPlatform], ) -> Result, String> { let mut endpoints = vec![ YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalog, platform: None, url: self.table_catalog(), }, YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalogHash, platform: None, url: self.table_catalog_hash(), }, ]; for platform in unique_platforms(platforms) { endpoints.push(YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::AddressablesCatalog, platform: Some(platform), url: self.addressables_catalog_zip(platform)?, }); endpoints.push(YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::AddressablesCatalogHash, platform: Some(platform), url: self.addressables_catalog_hash(platform)?, }); endpoints.push(YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::BundlePackingInfo, platform: Some(platform), url: self.bundle_packing_info(platform)?, }); endpoints.push(YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::BundlePackingInfoHash, platform: Some(platform), url: self.bundle_packing_hash(platform)?, }); endpoints.push(YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::MediaCatalog, platform: Some(platform), url: self.media_catalog(platform)?, }); endpoints.push(YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::MediaCatalogHash, platform: Some(platform), url: self.media_catalog_hash(platform)?, }); } Ok(endpoints) } } /// Builds an official server-info URL from the encrypted GameMainConfig token. pub fn server_info_url(file_name: &str) -> Result { validate_relative_path(file_name, "server-info file")?; Ok(format!( "{}/{}", SERVER_INFO_ROOT, normalize_relative_path(file_name) )) } /// Returns true when the URL is under a known official JP Yostar host. pub fn is_official_yostar_jp_url(url: &str) -> bool { let Some(rest) = url.strip_prefix("https://") else { return false; }; let host = rest.split('/').next().unwrap_or_default(); matches!( host, SERVER_INFO_HOST | CLIENT_PATCH_HOST | "prod-noticeindex.bluearchiveyostar.com" | "prod-notice.bluearchiveyostar.com" | "prod-game.bluearchiveyostar.com:5000" | "prod-gateway.bluearchiveyostar.com:5100" | "prod-logcollector.bluearchiveyostar.com:5300" | "bluearchive.jp" ) } fn validate_relative_segment(value: &str, label: &str) -> Result<(), String> { if value.is_empty() || value.contains('/') || value.contains('\\') || value.contains("..") || value.contains('=') || value.contains(':') || value.starts_with('.') { return Err(format!("Invalid {label}: {value}")); } Ok(()) } fn validate_relative_path(value: &str, label: &str) -> Result<(), String> { if value.is_empty() || value.starts_with('/') || value.starts_with('\\') || value.contains("..") || value.contains('=') || value.contains(':') { return Err(format!("Invalid {label}: {value}")); } Ok(()) } fn normalize_relative_path(path: &str) -> String { path.replace('\\', "/") } fn override_non_empty(target: &mut Option, source: &Option) { if source.as_deref().is_some_and(|value| !value.is_empty()) { *target = source.clone(); } } fn unique_platforms(platforms: &[PatchPlatform]) -> Vec { platforms .iter() .copied() .fold(Vec::new(), |mut unique, platform| { if !unique.contains(&platform) { unique.push(platform); } unique }) } #[cfg(test)] mod tests { use super::*; const ROOT: &str = "r93_dctuo3tcd029wwxnvb55"; const SERVER_INFO_JSON: &str = r#"{ "ConnectionGroups": [ { "Name": "Prod-Audit", "ManagementDataUrl": "https://prod-noticeindex.bluearchiveyostar.com/prod/index.json", "IsProductionAddressables": true, "ApiUrl": "https://prod-game.bluearchiveyostar.com:5000/api/", "GatewayUrl": "https://prod-gateway.bluearchiveyostar.com:5100/api/", "KibanaLogUrl": "https://prod-logcollector.bluearchiveyostar.com:5300", "ProhibitedWordBlackListUri": "https://prod-notice.bluearchiveyostar.com/prod/ProhibitedWord/blacklist.csv", "ProhibitedWordWhiteListUri": "https://prod-notice.bluearchiveyostar.com/prod/ProhibitedWord/whitelist.csv", "CustomerServiceUrl": "https://bluearchive.jp/contact-1-hint", "OverrideConnectionGroups": [ { "Name": "1.0", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/m28_1_0_1_mashiro3" }, { "Name": "1.70", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" } ], "BundleVersion": "s8tloc7lo3", "IsLivePublished": true } ] }"#; #[test] fn builds_server_info_url_from_file_name() { let url = server_info_url("r93_70_eaueguci86m4lr0zwa44.json").unwrap(); assert_eq!( url, "https://yostar-serverinfo.bluearchiveyostar.com/r93_70_eaueguci86m4lr0zwa44.json" ); } #[test] fn parses_server_info_and_selects_version_override() { let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); let group = server_info .connection_group("Prod-Audit", "1.70.0") .unwrap(); assert_eq!(group.name(), Some("Prod-Audit")); assert_eq!( group.addressables_catalog_url_root(), Some("https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55") ); assert_eq!(group.bundle_version(), Some("s8tloc7lo3")); assert_eq!(group.is_production_addressables(), Some(true)); assert!(group.is_live_published()); } #[test] fn builds_resource_root_from_selected_server_info() { let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); let root = server_info.resource_root("Prod-Audit", "1.70.2").unwrap(); assert_eq!( root.addressables_root(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" ); } #[test] fn builds_discovery_plan_from_selected_server_info() { let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); let plan = server_info .discovery_plan( "Prod-Audit", "1.70.0", &[PatchPlatform::Windows, PatchPlatform::Android], ) .unwrap(); assert_eq!(plan.connection_group_name, "Prod-Audit"); assert_eq!(plan.app_version, "1.70.0"); assert_eq!(plan.bundle_version.as_deref(), Some("s8tloc7lo3")); assert_eq!( plan.addressables_root, "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" ); assert_eq!(plan.endpoints.len(), 14); assert_eq!( plan.endpoints[0], YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalog, platform: None, url: "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/TableCatalog.bytes".to_string(), } ); assert!(plan.endpoints.iter().any(|endpoint| endpoint == &YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::AddressablesCatalog, platform: Some(PatchPlatform::Windows), url: "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(), })); assert!(plan.endpoints.iter().any(|endpoint| endpoint == &YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::MediaCatalog, platform: Some(PatchPlatform::Android), url: "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources/Catalog/MediaCatalog.bytes".to_string(), })); } #[test] fn sync_snapshot_reports_initial_state() { let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); let snapshot = server_info .sync_snapshot( "Prod-Audit", "1.70.0", &[PatchPlatform::Windows, PatchPlatform::Android], ) .unwrap(); let delta = snapshot.diff(None); assert!(delta.is_initial); assert!(delta.needs_download); assert!(delta.has_changes()); assert!(delta.endpoint_changes.is_empty()); } #[test] fn sync_snapshot_detects_root_and_hash_changes() { let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); let current = server_info .sync_snapshot("Prod-Audit", "1.70.0", &[PatchPlatform::Windows]) .unwrap(); let previous = YostarJpSyncSnapshot { connection_group_name: "Prod-Audit".to_string(), app_version: "1.70.0".to_string(), bundle_version: Some("s8tloc7lo2".to_string()), addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token".to_string(), endpoints: vec![ YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalog, platform: None, url: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token/TableBundles/TableCatalog.bytes".to_string(), }, YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalogHash, platform: None, url: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token/TableBundles/TableCatalog.hash".to_string(), }, YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::AddressablesCatalog, platform: Some(PatchPlatform::Windows), url: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(), }, ], }; let delta = current.diff(Some(&previous)); assert!(!delta.connection_group_changed); assert!(!delta.app_version_changed); assert!(delta.bundle_version_changed); assert!(delta.addressables_root_changed); assert!(delta.root_token_changed); assert!(delta.needs_download); assert!(!delta.endpoint_changes.is_empty()); } #[test] fn sync_delta_can_notice_bundle_version_without_root_change() { let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); let current = server_info .sync_snapshot("Prod-Audit", "1.70.0", &[PatchPlatform::Windows]) .unwrap(); let previous = YostarJpSyncSnapshot { connection_group_name: "Prod-Audit".to_string(), app_version: "1.70.0".to_string(), bundle_version: Some("older-bundle".to_string()), addressables_root: current.addressables_root.clone(), endpoints: current.endpoints.clone(), }; let delta = current.diff(Some(&previous)); assert!(delta.bundle_version_changed); assert!(!delta.addressables_root_changed); assert!(!delta.root_token_changed); } #[test] fn uses_first_prefix_override_and_falls_back_to_base_when_no_match() { let server_info = YostarJpServerInfo::from_json( r#"{ "ConnectionGroups": [ { "Name": "Prod-Audit", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/base_root", "OverrideConnectionGroups": [ { "Name": "1.70", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" }, { "Name": "1.70.0", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/too_late" } ], "BundleVersion": "base-bundle", "IsLivePublished": true } ] }"#, ) .unwrap(); let matched = server_info .connection_group("Prod-Audit", "1.70.0") .unwrap(); assert_eq!( matched.addressables_catalog_url_root(), Some("https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55") ); let fallback = server_info .connection_group("Prod-Audit", "1.69.0") .unwrap(); assert_eq!( fallback.addressables_catalog_url_root(), Some("https://prod-clientpatch.bluearchiveyostar.com/base_root") ); } #[test] fn keeps_base_fields_when_override_fields_are_empty() { let server_info = YostarJpServerInfo::from_json( r#"{ "ConnectionGroups": [ { "Name": "Prod-Audit", "ManagementDataUrl": "https://prod-noticeindex.bluearchiveyostar.com/prod/index.json", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/base_root", "ApiUrl": "https://prod-game.bluearchiveyostar.com:5000/api/", "OverrideConnectionGroups": [ { "Name": "1.70", "ManagementDataUrl": "", "AddressablesCatalogUrlRoot": "" } ], "BundleVersion": "base-bundle" } ] }"#, ) .unwrap(); let group = server_info .connection_group("Prod-Audit", "1.70.0") .unwrap(); assert_eq!( group.addressables_catalog_url_root(), Some("https://prod-clientpatch.bluearchiveyostar.com/base_root") ); assert_eq!(group.bundle_version(), Some("base-bundle")); } #[test] fn rejects_mirror_root_from_server_info() { let server_info = YostarJpServerInfo::from_json( r#"{ "ConnectionGroups": [ { "Name": "Prod-Audit", "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchive.cafe/r93_02_dctuo3tcd029wwxnvb55/text=jp" } ] }"#, ) .unwrap(); assert!(server_info.resource_root("Prod-Audit", "1.70.0").is_err()); } #[test] fn builds_windows_patch_strategy_urls() { let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); assert_eq!( root.addressables_catalog_zip(PatchPlatform::Windows).unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/catalog_StandaloneWindows64.zip" ); assert_eq!( root.addressables_catalog_hash(PatchPlatform::Windows).unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/catalog_StandaloneWindows64.hash" ); assert_eq!( root.bundle_packing_info(PatchPlatform::Windows).unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/BundlePackingInfo.bytes" ); assert_eq!( root.bundle_packing_hash(PatchPlatform::Windows).unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/BundlePackingInfo.hash" ); assert_eq!( root.bundle_patch_pack(PatchPlatform::Windows, "FullPatch_000.zip") .unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/FullPatch_000.zip" ); } #[test] fn builds_table_and_media_urls() { let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); assert_eq!( root.table_catalog(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/TableCatalog.bytes" ); assert_eq!( root.table_bundle("ExcelDB.db").unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/ExcelDB.db" ); assert_eq!( root.media_catalog(PatchPlatform::Windows).unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes" ); assert_eq!( root.media_file(PatchPlatform::Windows, "JP_Airi.zip").unwrap(), "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/JP_Airi.zip" ); } #[test] fn builds_seed_endpoints_once_per_unique_platform() { let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); let endpoints = root .seed_endpoints(&[ PatchPlatform::Windows, PatchPlatform::Windows, PatchPlatform::Android, ]) .unwrap(); assert_eq!(endpoints.len(), 14); assert_eq!( endpoints .iter() .filter(|endpoint| endpoint.platform == Some(PatchPlatform::Windows)) .count(), 6 ); assert_eq!( endpoints .iter() .filter(|endpoint| endpoint.platform == Some(PatchPlatform::Android)) .count(), 6 ); assert_eq!( endpoints .iter() .filter(|endpoint| endpoint.platform.is_none()) .count(), 2 ); } #[test] fn accepts_only_official_addressables_root() { let official = format!("{CLIENT_PATCH_ROOT}/{ROOT}"); assert_eq!( YostarJpResourceRoot::from_addressables_root(&official) .unwrap() .addressables_root(), official ); let mirror = "https://prod-clientpatch.bluearchive.cafe/r93_02_dctuo3tcd029wwxnvb55"; assert!(YostarJpResourceRoot::from_addressables_root(mirror).is_err()); } #[test] fn detects_official_hosts_without_accepting_mirrors() { assert!(is_official_yostar_jp_url( "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/ExcelDB.db" )); assert!(is_official_yostar_jp_url( "https://bluearchive.jp/contact-1-hint" )); assert!(!is_official_yostar_jp_url( "https://prod-clientpatch.bluearchive.cafe/r93_02_dctuo3tcd029wwxnvb55/text=jp/voice=jp/media=jp/TableBundles/ExcelDB.db" )); } #[test] fn rejects_mirror_path_layers_and_absolute_paths() { let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); assert!( YostarJpResourceRoot::from_root_token("r93_02_dctuo3tcd029wwxnvb55/text=jp").is_err() ); assert!(root.table_bundle("text=jp/ExcelDB.db").is_err()); assert!(root .media_file(PatchPlatform::Windows, "/JP_Airi.zip") .is_err()); assert!(root .bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip") .is_err()); } }