mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 00:55:15 +08:00
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8
1221 lines
44 KiB
Rust
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 ¤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<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());
|
|
}
|
|
}
|