Files
BlueArchiveToolkit/adapters/src/official/yostar_jp.rs
T
nyaKazuha 90307a3243 feat: track official versions and import resources
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
2026-07-15 19:53:10 +08:00

1221 lines
44 KiB
Rust

//! 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<YostarJpConnectionGroup>,
}
impl YostarJpServerInfo {
/// Parses a server-info JSON document.
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(data)
}
/// Parses a server-info JSON string.
pub fn from_json(data: &str) -> Result<Self, serde_json::Error> {
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<YostarJpConnectionGroup, String> {
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<YostarJpResourceRoot, String> {
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<YostarJpResourceDiscoveryPlan, String> {
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<YostarJpSyncSnapshot, String> {
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<String>,
#[serde(default)]
management_data_url: Option<String>,
#[serde(default)]
addressables_catalog_url_root: Option<String>,
#[serde(default)]
is_production_addressables: Option<bool>,
#[serde(default)]
api_url: Option<String>,
#[serde(default)]
gateway_url: Option<String>,
#[serde(default)]
kibana_log_url: Option<String>,
#[serde(default)]
chatting_server_address: Option<String>,
#[serde(default)]
prohibited_word_black_list_uri: Option<String>,
#[serde(default)]
prohibited_word_white_list_uri: Option<String>,
#[serde(default)]
customer_service_url: Option<String>,
#[serde(default)]
override_connection_groups: Vec<YostarJpConnectionGroup>,
#[serde(default)]
bundle_version: Option<String>,
#[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<bool> {
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<YostarJpResourceRoot, String> {
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<PatchPlatform> {
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<PatchPlatform>,
/// 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<String>,
/// Selected official `AddressablesCatalogUrlRoot`.
pub addressables_root: String,
/// Seed URLs that should be fetched before parsing downstream catalogs.
pub endpoints: Vec<YostarJpResourceEndpoint>,
}
/// 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<String>,
/// Selected official `AddressablesCatalogUrlRoot`.
pub addressables_root: String,
/// Seed URLs that should be checked or fetched.
pub endpoints: Vec<YostarJpResourceEndpoint>,
}
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<YostarJpResourceEndpointChange>,
/// 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<YostarJpResourceEndpointChange> {
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 &current_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<PatchPlatform>) {
(
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<PatchPlatform>) {
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<Self, String> {
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<Self, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<Vec<YostarJpResourceEndpoint>, 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<String, String> {
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<String>, source: &Option<String>) {
if source.as_deref().is_some_and(|value| !value.is_empty()) {
*target = source.clone();
}
}
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
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());
}
}