Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
This commit is contained in:
61
server-rs/Cargo.lock
generated
61
server-rs/Cargo.lock
generated
@@ -637,6 +637,36 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curl"
|
||||
version = "0.4.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc"
|
||||
dependencies = [
|
||||
"curl-sys",
|
||||
"libc",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"socket2 0.6.3",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curl-sys"
|
||||
version = "0.4.88+curl-8.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "644816de6547255eff4e491a1dda1c19b7237f00b62a61e6e64859ce4f2906d0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1311,7 +1341,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -1669,6 +1699,18 @@ dependencies = [
|
||||
"glob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2415,6 +2457,7 @@ name = "platform-image"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"curl",
|
||||
"image",
|
||||
"platform-oss",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2446,6 +2489,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2615,7 +2659,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2652,7 +2696,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -4582,7 +4626,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4662,6 +4706,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
||||
@@ -98,6 +98,7 @@ axum = "0.8"
|
||||
base64 = "0.22"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
bytes = "1"
|
||||
curl = "0.4"
|
||||
dotenvy = "0.15"
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
|
||||
@@ -55,7 +55,7 @@ shared-kernel = { workspace = true }
|
||||
shared-logging = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
spacetime-client = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] }
|
||||
tokio-stream = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
|
||||
@@ -878,6 +878,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readyz_reports_readiness_and_draining_state() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let app = build_router(state.clone());
|
||||
|
||||
let ready_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/readyz")
|
||||
.header("x-request-id", "req-ready")
|
||||
.body(Body::empty())
|
||||
.expect("readyz request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("readyz request should succeed");
|
||||
assert_eq!(ready_response.status(), StatusCode::OK);
|
||||
let ready_body = read_json_response(ready_response).await;
|
||||
assert_eq!(ready_body["ok"], Value::Bool(true));
|
||||
assert_eq!(ready_body["ready"], Value::Bool(true));
|
||||
|
||||
state.mark_not_ready();
|
||||
let draining_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/readyz")
|
||||
.header("x-request-id", "req-draining")
|
||||
.body(Body::empty())
|
||||
.expect("readyz request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("readyz request should succeed");
|
||||
assert_eq!(draining_response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let draining_body = read_json_response(draining_response).await;
|
||||
assert_eq!(
|
||||
draining_body["error"]["details"]["reason"],
|
||||
"api_server_draining"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
@@ -2658,6 +2698,18 @@ mod tests {
|
||||
bind_payload["user"]["phoneNumberMasked"],
|
||||
Value::String("138****8000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bind_payload["user"]["phoneNumber"],
|
||||
Value::String("+8613800138000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bind_payload["user"]["wechatAccount"],
|
||||
Value::String("wx-mini-code-bind-001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bind_payload["user"]["wechatDisplayName"],
|
||||
Value::String("微信旅人".to_string())
|
||||
);
|
||||
assert!(
|
||||
bind_payload["token"]
|
||||
.as_str()
|
||||
@@ -3345,6 +3397,10 @@ mod tests {
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
|
||||
assert_eq!(
|
||||
payload["user"]["phoneNumber"],
|
||||
Value::String("+8613800138016".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "password", "wechat"])
|
||||
|
||||
@@ -7,10 +7,13 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
|
||||
public_user_code: user.public_user_code,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
phone_number: user.phone_number,
|
||||
phone_number_masked: user.phone_number_masked,
|
||||
login_method: user.login_method.as_str().to_string(),
|
||||
binding_status: user.binding_status.as_str().to_string(),
|
||||
wechat_bound: user.wechat_bound,
|
||||
wechat_display_name: user.wechat_display_name,
|
||||
wechat_account: user.wechat_account,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ fn reject_overloaded_request(request: &Request<Body>) -> Response {
|
||||
}
|
||||
|
||||
fn should_bypass_backpressure(request: &Request<Body>) -> bool {
|
||||
request.uri().path() == "/healthz"
|
||||
matches!(request.uri().path(), "/healthz" | "/readyz")
|
||||
}
|
||||
|
||||
fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind {
|
||||
@@ -200,6 +200,7 @@ mod tests {
|
||||
.route("/held", get(held_request))
|
||||
.route("/fast", get(fast_request))
|
||||
.route("/healthz", get(fast_request))
|
||||
.route("/readyz", get(fast_request))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
backpressure_state,
|
||||
limit_concurrent_requests,
|
||||
@@ -297,6 +298,13 @@ mod tests {
|
||||
.expect("healthz request should complete");
|
||||
assert_eq!(health_response.status(), StatusCode::OK);
|
||||
|
||||
let ready_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/readyz"))
|
||||
.await
|
||||
.expect("readyz request should complete");
|
||||
assert_eq!(ready_response.status(), StatusCode::OK);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
|
||||
@@ -30,7 +30,7 @@ use shared_kernel::{
|
||||
use spacetime_client::{
|
||||
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
|
||||
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
|
||||
BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||
BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
|
||||
@@ -406,6 +406,38 @@ pub async fn list_bark_battle_works(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_bark_battle_work(
|
||||
State(state): State<AppState>,
|
||||
Path(work_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.delete_bark_battle_work(BarkBattleWorkDeleteRecordInput {
|
||||
work_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let author_display_name =
|
||||
resolve_bark_battle_author_display_name_for_record(&state, &item);
|
||||
map_work_summary_record(item, &request_context, author_display_name)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
BarkBattleWorksResponse { items },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_bark_battle_gallery(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -25,6 +25,7 @@ pub struct AppConfig {
|
||||
pub gallery_max_concurrent_requests: Option<usize>,
|
||||
pub detail_max_concurrent_requests: Option<usize>,
|
||||
pub admin_max_concurrent_requests: Option<usize>,
|
||||
pub shutdown_outbox_flush_timeout: Duration,
|
||||
pub tracking_outbox_enabled: bool,
|
||||
pub tracking_outbox_dir: PathBuf,
|
||||
pub tracking_outbox_batch_size: usize,
|
||||
@@ -169,6 +170,7 @@ impl Default for AppConfig {
|
||||
gallery_max_concurrent_requests: None,
|
||||
detail_max_concurrent_requests: None,
|
||||
admin_max_concurrent_requests: None,
|
||||
shutdown_outbox_flush_timeout: Duration::from_millis(5_000),
|
||||
tracking_outbox_enabled: true,
|
||||
tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"),
|
||||
tracking_outbox_batch_size: 500,
|
||||
@@ -365,6 +367,11 @@ impl AppConfig {
|
||||
{
|
||||
config.admin_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(timeout_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"])
|
||||
{
|
||||
config.shutdown_outbox_flush_timeout = Duration::from_millis(timeout_ms);
|
||||
}
|
||||
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) {
|
||||
config.tracking_outbox_enabled = enabled;
|
||||
}
|
||||
@@ -1324,6 +1331,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
@@ -1336,6 +1344,7 @@ mod tests {
|
||||
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
|
||||
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
|
||||
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
|
||||
std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_TRACKING_OUTBOX_DIR",
|
||||
@@ -1354,6 +1363,10 @@ mod tests {
|
||||
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
|
||||
assert_eq!(config.detail_max_concurrent_requests, Some(32));
|
||||
assert_eq!(config.admin_max_concurrent_requests, Some(16));
|
||||
assert_eq!(
|
||||
config.shutdown_outbox_flush_timeout,
|
||||
std::time::Duration::from_millis(3_000)
|
||||
);
|
||||
assert!(!config.tracking_outbox_enabled);
|
||||
assert_eq!(
|
||||
config.tracking_outbox_dir,
|
||||
@@ -1374,6 +1387,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
|
||||
@@ -277,6 +277,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
|
||||
let config = test_creation_entry_config_response();
|
||||
let jump_hop = config
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "jump-hop")
|
||||
.expect("test creation entry config should include jump-hop");
|
||||
|
||||
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
|
||||
assert!(jump_hop.visible);
|
||||
assert!(jump_hop.open);
|
||||
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
|
||||
assert_eq!(
|
||||
jump_hop.subtitle,
|
||||
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
|
||||
);
|
||||
assert_eq!(
|
||||
jump_hop.image_src,
|
||||
"/creation-type-references/jump-hop.webp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
||||
let config = test_creation_entry_config_response();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::StatusCode;
|
||||
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
|
||||
|
||||
use crate::{
|
||||
@@ -8,9 +8,12 @@ use crate::{
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use generated_asset_sheets_impl::{
|
||||
GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
|
||||
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
|
||||
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
|
||||
crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
};
|
||||
|
||||
pub(crate) fn build_generated_asset_sheet_prompt(
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
use axum::{Json, extract::Extension};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext};
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn health_check(Extension(request_context): Extension<RequestContext>) -> Json<Value> {
|
||||
json_success_body(
|
||||
@@ -12,3 +20,28 @@ pub async fn health_check(Extension(request_context): Extension<RequestContext>)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn readiness_check(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Response {
|
||||
if state.is_ready() {
|
||||
return json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"ok": true,
|
||||
"ready": true,
|
||||
"service": "genarrative-api-server",
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("api-server 正在退出,不再接收新流量")
|
||||
.with_details(json!({
|
||||
"reason": "api_server_draining",
|
||||
"ready": false,
|
||||
}))
|
||||
.into_response_with_context(Some(&request_context))
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -100,25 +100,35 @@ use shared_logging::{OtelConfig, init_tracing};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
env, fs, io,
|
||||
env, fs, future, io,
|
||||
net::{SocketAddr, TcpListener as StdTcpListener},
|
||||
panic, thread,
|
||||
panic,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
app::{build_router, build_spacetime_unavailable_router},
|
||||
config::AppConfig,
|
||||
state::{AppState, AppStateInitError},
|
||||
tracking_outbox::TrackingOutbox,
|
||||
};
|
||||
|
||||
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ShutdownContext {
|
||||
app_state: Option<AppState>,
|
||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||
outbox_flush_timeout: Duration,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), io::Error> {
|
||||
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
|
||||
let server_thread = thread::Builder::new()
|
||||
@@ -159,19 +169,33 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
let listen_backlog = config.listen_backlog;
|
||||
let worker_threads = config.worker_threads;
|
||||
let otel_enabled = config.otel_enabled;
|
||||
let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
|
||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||
|
||||
let router = match restore_app_state_for_startup(config).await {
|
||||
let (router, shutdown_context) = match restore_app_state_for_startup(config).await {
|
||||
Ok(state) => {
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
let tracking_outbox = state.tracking_outbox();
|
||||
if let Some(outbox) = tracking_outbox.clone() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
build_router(state)
|
||||
}
|
||||
Err(AppStateInitError::DependencyUnavailable(message)) => {
|
||||
build_spacetime_unavailable_router(message)
|
||||
(
|
||||
build_router(state.clone()),
|
||||
ShutdownContext {
|
||||
app_state: Some(state),
|
||||
tracking_outbox,
|
||||
outbox_flush_timeout,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(AppStateInitError::DependencyUnavailable(message)) => (
|
||||
build_spacetime_unavailable_router(message),
|
||||
ShutdownContext {
|
||||
app_state: None,
|
||||
tracking_outbox: None,
|
||||
outbox_flush_timeout,
|
||||
},
|
||||
),
|
||||
Err(error) => {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"初始化应用状态失败:{error}"
|
||||
@@ -187,7 +211,98 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
"api-server 已完成 tracing 初始化并开始监听"
|
||||
);
|
||||
|
||||
axum::serve(listener, router).await
|
||||
let result = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()))
|
||||
.await;
|
||||
finalize_shutdown(shutdown_context).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn shutdown_signal(context: ShutdownContext) {
|
||||
let signal = wait_for_shutdown_signal().await;
|
||||
if let Some(state) = context.app_state.as_ref() {
|
||||
state.mark_not_ready();
|
||||
}
|
||||
info!(
|
||||
signal,
|
||||
"api-server 收到退出信号,已标记 readiness 不可用并开始排空 HTTP 请求"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown_signal() -> &'static str {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
tokio::select! {
|
||||
signal = wait_for_ctrl_c_signal() => signal,
|
||||
signal = wait_for_sigterm_signal() => signal,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
wait_for_ctrl_c_signal().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_ctrl_c_signal() -> &'static str {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
error!(error = %error, "监听 SIGINT 失败,无法通过 Ctrl-C 触发优雅退出");
|
||||
future::pending::<()>().await;
|
||||
}
|
||||
"sigint"
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn wait_for_sigterm_signal() -> &'static str {
|
||||
let mut signal = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
{
|
||||
Ok(signal) => signal,
|
||||
Err(error) => {
|
||||
error!(error = %error, "监听 SIGTERM 失败,无法通过 systemd terminate 触发优雅退出");
|
||||
future::pending::<()>().await;
|
||||
unreachable!("pending future never returns");
|
||||
}
|
||||
};
|
||||
signal.recv().await;
|
||||
"sigterm"
|
||||
}
|
||||
|
||||
async fn finalize_shutdown(context: ShutdownContext) {
|
||||
if let Some(state) = context.app_state.as_ref() {
|
||||
state.mark_not_ready();
|
||||
}
|
||||
|
||||
let Some(outbox) = context.tracking_outbox else {
|
||||
return;
|
||||
};
|
||||
|
||||
if context.outbox_flush_timeout.is_zero() {
|
||||
warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout_ms = context
|
||||
.outbox_flush_timeout
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64;
|
||||
info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox");
|
||||
match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await {
|
||||
Ok(Ok(())) => {
|
||||
info!("api-server 退出前 tracking outbox flush 完成");
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!(
|
||||
error = %error,
|
||||
"api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
timeout_ms,
|
||||
"api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tcp_listener(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset,
|
||||
get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery,
|
||||
list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run,
|
||||
update_bark_battle_draft_config,
|
||||
create_bark_battle_draft, delete_bark_battle_work, finish_bark_battle_run,
|
||||
generate_bark_battle_image_asset, get_bark_battle_run, get_bark_battle_runtime_config,
|
||||
list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
|
||||
start_bark_battle_run, update_bark_battle_draft_config,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -51,6 +51,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}",
|
||||
delete(delete_bark_battle_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/gallery",
|
||||
get(list_bark_battle_gallery),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use crate::{health::health_check, state::AppState};
|
||||
use crate::{
|
||||
health::{health_check, readiness_check},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router(_state: AppState) -> Router<AppState> {
|
||||
Router::new().route("/healthz", get(health_check))
|
||||
Router::new()
|
||||
.route("/healthz", get(health_check))
|
||||
.route("/readyz", get(readiness_check))
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
jump_hop::{
|
||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
||||
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
|
||||
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
|
||||
get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works,
|
||||
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -43,6 +45,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}",
|
||||
delete(delete_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}/publish",
|
||||
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||
@@ -54,6 +63,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/jump-hop/works/{profile_id}",
|
||||
get(get_jump_hop_runtime_work),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/works/{profile_id}/leaderboard",
|
||||
get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/runs",
|
||||
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
state::AppState,
|
||||
wooden_fish::{
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
|
||||
get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
|
||||
publish_wooden_fish_work, start_wooden_fish_run,
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, delete_wooden_fish_work,
|
||||
execute_wooden_fish_action, finish_wooden_fish_run, get_wooden_fish_gallery_detail,
|
||||
get_wooden_fish_runtime_work, get_wooden_fish_session, list_wooden_fish_gallery,
|
||||
list_wooden_fish_works, publish_wooden_fish_work, start_wooden_fish_run,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,6 +44,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works/{profile_id}",
|
||||
delete(delete_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works/{profile_id}/publish",
|
||||
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||
|
||||
static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn puzzle_background_compile_tasks() -> &'static Mutex<HashSet<String>> {
|
||||
PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn try_register_puzzle_background_compile_task(session_id: &str) -> bool {
|
||||
match puzzle_background_compile_tasks().lock() {
|
||||
Ok(mut tasks) => tasks.insert(session_id.to_string()),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
error = %error,
|
||||
"拼图后台生成任务注册表锁已损坏,允许本次任务继续"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unregister_puzzle_background_compile_task(session_id: &str) {
|
||||
match puzzle_background_compile_tasks().lock() {
|
||||
Ok(mut tasks) => {
|
||||
tasks.remove(session_id);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
error = %error,
|
||||
"拼图后台生成任务注册表解锁失败,忽略清理"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_puzzle_cover_image_src(value: &Option<String>) -> bool {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn mark_puzzle_initial_generation_started_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
session.stage = "image_refining".to_string();
|
||||
session.progress_percent = session.progress_percent.max(88);
|
||||
if let Some(draft) = session.draft.as_mut() {
|
||||
let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
|
||||
if let Some(primary_level) = draft.levels.first_mut() {
|
||||
if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
|
||||
primary_level.generation_status = "generating".to_string();
|
||||
}
|
||||
draft.generation_status = primary_level.generation_status.clone();
|
||||
draft.candidates = primary_level.candidates.clone();
|
||||
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
|
||||
draft.cover_image_src = primary_level.cover_image_src.clone();
|
||||
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
||||
} else if draft_needs_cover {
|
||||
draft.generation_status = "generating".to_string();
|
||||
}
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
@@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
.or_else(|| levels.first())
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: String,
|
||||
compiled_session: PuzzleAgentSessionRecord,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
reference_image_src: Option<&str>,
|
||||
image_model: Option<&str>,
|
||||
now: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error)?;
|
||||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
match state
|
||||
.spacetime_client()
|
||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||
session_id,
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id,
|
||||
level_id: Some(target_level.level_id),
|
||||
candidate_id: selected_candidate_id,
|
||||
|
||||
@@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
session_id,
|
||||
owner_user_id,
|
||||
error_message,
|
||||
failed_at_micros: now,
|
||||
failed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
@@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action(
|
||||
Err(response) => return Err(response),
|
||||
};
|
||||
let session = if ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
&request_context,
|
||||
if !try_register_puzzle_background_compile_task(&compile_session_id) {
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compile_session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
"拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
|
||||
);
|
||||
state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map(mark_puzzle_initial_generation_started_snapshot)
|
||||
.map_err(map_puzzle_client_error)
|
||||
} else {
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
primary_reference_image_src,
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error);
|
||||
match compiled_session {
|
||||
Ok(compiled_session) => {
|
||||
let response_session =
|
||||
mark_puzzle_initial_generation_started_snapshot(
|
||||
compiled_session.clone(),
|
||||
);
|
||||
let background_state = state.clone();
|
||||
let background_request_context = request_context.clone();
|
||||
let background_session_id = compile_session_id.clone();
|
||||
let background_owner_user_id = owner_user_id.clone();
|
||||
let background_prompt_text = prompt_text.map(str::to_string);
|
||||
let background_reference_image_src =
|
||||
primary_reference_image_src.map(str::to_string);
|
||||
let background_image_model = payload.image_model.clone();
|
||||
let background_billing_asset_id =
|
||||
format!("{background_session_id}:compile_puzzle_draft");
|
||||
tokio::spawn(async move {
|
||||
let operation_owner_user_id =
|
||||
background_owner_user_id.clone();
|
||||
let background_root_state =
|
||||
background_state.root_state().clone();
|
||||
let operation_state = background_state.clone();
|
||||
let result = execute_billable_asset_operation_with_cost(
|
||||
&background_root_state,
|
||||
&background_owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&background_billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async move {
|
||||
generate_puzzle_initial_cover_from_compiled_session(
|
||||
&operation_state,
|
||||
&background_request_context,
|
||||
compiled_session,
|
||||
operation_owner_user_id,
|
||||
background_prompt_text.as_deref(),
|
||||
background_reference_image_src.as_deref(),
|
||||
background_image_model.as_deref(),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(session) => {
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session.session_id,
|
||||
owner_user_id = %background_owner_user_id,
|
||||
"拼图首图后台生成任务完成"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let error_message = error.body_text();
|
||||
let failure_result = background_state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_draft_generation_failed(
|
||||
PuzzleDraftCompileFailureRecordInput {
|
||||
session_id: background_session_id.clone(),
|
||||
owner_user_id: background_owner_user_id
|
||||
.clone(),
|
||||
error_message: error_message.clone(),
|
||||
failed_at_micros: current_utc_micros(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(mark_error) = failure_result {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %background_session_id,
|
||||
owner_user_id = %background_owner_user_id,
|
||||
message = %mark_error,
|
||||
"拼图首图后台生成失败态回写失败"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %background_session_id,
|
||||
owner_user_id = %background_owner_user_id,
|
||||
message = %error_message,
|
||||
"拼图首图后台生成任务失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
unregister_puzzle_background_compile_task(
|
||||
&background_session_id,
|
||||
);
|
||||
});
|
||||
Ok(response_session)
|
||||
}
|
||||
Err(error) => {
|
||||
unregister_puzzle_background_compile_task(&compile_session_id);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
compile_puzzle_draft_with_uploaded_cover(
|
||||
&state,
|
||||
@@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
"compile_puzzle_draft",
|
||||
"首关拼图草稿",
|
||||
if ai_redraw {
|
||||
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
|
||||
"已编译首关草稿,并启动首关画面和 UI 资产后台生成。"
|
||||
} else {
|
||||
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
|
||||
},
|
||||
|
||||
@@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_compile_started_snapshot_marks_primary_level_generating() {
|
||||
let mut session = PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 88,
|
||||
stage: "draft_ready".to_string(),
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
draft: Some(test_puzzle_draft_record()),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
{
|
||||
let draft = session.draft.as_mut().expect("draft");
|
||||
draft.generation_status = "idle".to_string();
|
||||
draft.levels[0].generation_status = "idle".to_string();
|
||||
draft.levels[0].cover_image_src = None;
|
||||
draft.levels[0].cover_asset_id = None;
|
||||
}
|
||||
|
||||
let session = mark_puzzle_initial_generation_started_snapshot(session);
|
||||
let draft = session.draft.expect("draft");
|
||||
|
||||
assert_eq!(session.stage, "image_refining");
|
||||
assert_eq!(draft.generation_status, "generating");
|
||||
assert_eq!(draft.levels[0].generation_status, "generating");
|
||||
assert!(draft.cover_image_src.is_none());
|
||||
assert!(draft.levels[0].cover_image_src.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
@@ -2,7 +2,10 @@ use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
@@ -229,6 +232,7 @@ pub struct AppStateInner {
|
||||
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
ready: AtomicBool,
|
||||
http_request_permit_pools: HttpRequestPermitPools,
|
||||
auth_jwt_config: JwtConfig,
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
@@ -399,6 +403,7 @@ impl AppState {
|
||||
|
||||
Ok(Self(Arc::new(AppStateInner {
|
||||
config,
|
||||
ready: AtomicBool::new(true),
|
||||
http_request_permit_pools,
|
||||
auth_jwt_config,
|
||||
admin_runtime,
|
||||
@@ -447,6 +452,14 @@ impl AppState {
|
||||
self.http_request_permit_pools.clone()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.ready.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn mark_not_ready(&self) {
|
||||
self.ready.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub async fn upsert_creation_entry_type_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryTypeAdminUpsertInput,
|
||||
|
||||
@@ -159,6 +159,16 @@ impl TrackingOutbox {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn flush_for_shutdown(&self) -> Result<(), TrackingOutboxError> {
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await?;
|
||||
self.seal_active_locked(&mut inner, "shutdown").await?;
|
||||
}
|
||||
|
||||
self.flush_sealed_files_once().await
|
||||
}
|
||||
|
||||
async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await?;
|
||||
@@ -176,7 +186,11 @@ impl TrackingOutbox {
|
||||
crate::telemetry::update_tracking_outbox_pending_files(sealed_files.len());
|
||||
for path in sealed_files {
|
||||
let started_at = Instant::now();
|
||||
let metadata = fs::metadata(&path).await?;
|
||||
let metadata = match fs::metadata(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let file_bytes = metadata.len();
|
||||
let events = match read_outbox_events(&path).await {
|
||||
Ok(events) => events,
|
||||
@@ -203,7 +217,11 @@ impl TrackingOutbox {
|
||||
|
||||
match self.spacetime_client.record_tracking_events(events).await {
|
||||
Ok(accepted_count) => {
|
||||
fs::remove_file(&path).await?;
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
self.subtract_total_bytes(file_bytes).await;
|
||||
crate::telemetry::record_tracking_outbox_flush(
|
||||
started_at.elapsed(),
|
||||
@@ -596,6 +614,34 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_flush_seals_active_file_for_later_retry() {
|
||||
let dir = test_dir("shutdown");
|
||||
let outbox = test_outbox(dir.clone(), 500, 1024 * 1024);
|
||||
|
||||
outbox.enqueue(sample_event("event-1")).await.unwrap();
|
||||
let result = outbox.flush_for_shutdown().await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(TrackingOutboxError::Spacetime(_))),
|
||||
"missing test SpacetimeDB should keep sealed file for retry"
|
||||
);
|
||||
assert!(!dir.join(ACTIVE_FILE_NAME).exists());
|
||||
let sealed_count = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(sealed_count, 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_size_excludes_quarantined_corrupt_files() {
|
||||
let dir = test_dir("directory-size");
|
||||
|
||||
@@ -229,6 +229,33 @@ pub async fn list_wooden_fish_works(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_wooden_fish_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||||
let works = state
|
||||
.spacetime_client()
|
||||
.delete_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
&request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
WoodenFishWorksResponse {
|
||||
items: works.into_iter().map(|work| work.summary).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_wooden_fish_runtime_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
|
||||
@@ -57,10 +57,16 @@ pub struct AuthUser {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub phone_number: Option<String>,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: AuthLoginMethod,
|
||||
pub binding_status: AuthBindingStatus,
|
||||
pub wechat_bound: bool,
|
||||
#[serde(default)]
|
||||
pub wechat_display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub wechat_account: Option<String>,
|
||||
pub token_version: u64,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
|
||||
@@ -97,6 +97,33 @@ struct StoredWechatIdentity {
|
||||
session_key: Option<String>,
|
||||
}
|
||||
|
||||
fn hydrate_private_auth_fields(
|
||||
state: &InMemoryAuthStoreState,
|
||||
stored_user: &StoredPasswordUser,
|
||||
) -> StoredPasswordUser {
|
||||
let mut hydrated = stored_user.clone();
|
||||
if hydrated.user.phone_number.is_none() {
|
||||
hydrated.user.phone_number = hydrated.phone_number.clone();
|
||||
}
|
||||
let hydrated_wechat_identity = state
|
||||
.wechat_identity_by_provider_uid
|
||||
.values()
|
||||
.find(|identity| identity.user_id == hydrated.user.id);
|
||||
if hydrated.user.wechat_display_name.is_none() {
|
||||
hydrated.user.wechat_display_name = hydrated_wechat_identity
|
||||
.and_then(|identity| identity.display_name.clone())
|
||||
.or_else(|| {
|
||||
(hydrated.user.login_method == AuthLoginMethod::Wechat)
|
||||
.then(|| hydrated.user.display_name.clone())
|
||||
});
|
||||
}
|
||||
if hydrated.user.wechat_account.is_none() {
|
||||
hydrated.user.wechat_account =
|
||||
hydrated_wechat_identity.map(|identity| identity.provider_uid.clone());
|
||||
}
|
||||
hydrated
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PasswordEntryService {
|
||||
store: InMemoryAuthStore,
|
||||
@@ -1067,7 +1094,7 @@ impl InMemoryAuthStore {
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
.cloned())
|
||||
.map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
|
||||
}
|
||||
|
||||
fn ensure_orphan_work_owner_user(
|
||||
@@ -1107,10 +1134,13 @@ impl InMemoryAuthStore {
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
phone_number: None,
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
wechat_display_name: None,
|
||||
wechat_account: None,
|
||||
token_version: 1,
|
||||
created_at,
|
||||
};
|
||||
@@ -1141,7 +1171,7 @@ impl InMemoryAuthStore {
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.public_user_code == public_user_code)
|
||||
.cloned())
|
||||
.map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
|
||||
}
|
||||
|
||||
fn find_by_phone_number(
|
||||
@@ -1152,7 +1182,8 @@ impl InMemoryAuthStore {
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
Ok(Self::resolve_phone_user_locked(&mut state, phone_number))
|
||||
Ok(Self::resolve_phone_user_locked(&mut state, phone_number)
|
||||
.map(|stored_user| hydrate_private_auth_fields(&state, &stored_user)))
|
||||
}
|
||||
|
||||
fn find_by_phone_number_for_password(
|
||||
@@ -1163,7 +1194,8 @@ impl InMemoryAuthStore {
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
Ok(Self::resolve_phone_user_locked(&mut state, phone_number))
|
||||
Ok(Self::resolve_phone_user_locked(&mut state, phone_number)
|
||||
.map(|stored_user| hydrate_private_auth_fields(&state, &stored_user)))
|
||||
}
|
||||
|
||||
fn update_user_profile(
|
||||
@@ -1226,10 +1258,13 @@ impl InMemoryAuthStore {
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
phone_number: Some(phone_number.e164.clone()),
|
||||
phone_number_masked: Some(phone_number.masked_national_number.clone()),
|
||||
login_method: AuthLoginMethod::Phone,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
wechat_display_name: None,
|
||||
wechat_account: None,
|
||||
token_version: 1,
|
||||
created_at,
|
||||
};
|
||||
@@ -1278,10 +1313,13 @@ impl InMemoryAuthStore {
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
phone_number: Some(phone_number.e164.clone()),
|
||||
phone_number_masked: Some(phone_number.masked_national_number.clone()),
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
wechat_display_name: None,
|
||||
wechat_account: None,
|
||||
token_version: 1,
|
||||
created_at,
|
||||
};
|
||||
@@ -1327,17 +1365,23 @@ impl InMemoryAuthStore {
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("微信旅人")
|
||||
.to_string();
|
||||
let wechat_display_name = normalize_optional_string(profile.display_name.clone())
|
||||
.or_else(|| Some(display_name.clone()));
|
||||
let username = build_wechat_username(&display_name, &profile.provider_uid);
|
||||
let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default();
|
||||
let user = AuthUser {
|
||||
id: user_id.clone(),
|
||||
public_user_code,
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: avatar_url.clone(),
|
||||
phone_number: None,
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Wechat,
|
||||
binding_status: AuthBindingStatus::PendingBindPhone,
|
||||
wechat_bound: true,
|
||||
wechat_display_name,
|
||||
wechat_account: Some(provider_uid.clone()),
|
||||
token_version: 1,
|
||||
created_at,
|
||||
};
|
||||
@@ -1352,7 +1396,7 @@ impl InMemoryAuthStore {
|
||||
);
|
||||
let identity = StoredWechatIdentity {
|
||||
user_id: user_id.clone(),
|
||||
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
|
||||
provider_uid,
|
||||
provider_union_id: normalize_optional_string(profile.provider_union_id),
|
||||
display_name: normalize_optional_string(profile.display_name),
|
||||
avatar_url,
|
||||
@@ -1390,7 +1434,7 @@ impl InMemoryAuthStore {
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == *user_id)
|
||||
{
|
||||
return Ok(Some(stored.user.clone()));
|
||||
return Ok(Some(hydrate_private_auth_fields(&state, stored).user));
|
||||
}
|
||||
|
||||
let Some(identity) = state
|
||||
@@ -1403,7 +1447,7 @@ impl InMemoryAuthStore {
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == identity.user_id)
|
||||
.map(|stored| stored.user.clone()))
|
||||
.map(|stored| hydrate_private_auth_fields(&state, stored).user))
|
||||
}
|
||||
|
||||
fn get_wechat_identity_by_user_id(
|
||||
@@ -1492,6 +1536,10 @@ impl InMemoryAuthStore {
|
||||
{
|
||||
stored_user.user.display_name = display_name.to_string();
|
||||
}
|
||||
stored_user.user.wechat_account = Some(next_provider_uid.clone());
|
||||
if let Some(display_name) = next_display_name.clone() {
|
||||
stored_user.user.wechat_display_name = Some(display_name);
|
||||
}
|
||||
stored_user.user.clone()
|
||||
};
|
||||
self.persist_wechat_state(&state)?;
|
||||
@@ -1728,6 +1776,8 @@ impl InMemoryAuthStore {
|
||||
.find(|identity| identity.user_id == pending_user_id)
|
||||
.cloned()
|
||||
.ok_or(PhoneAuthError::UserStateMismatch)?;
|
||||
let pending_wechat_account = pending_wechat_identity.provider_uid.clone();
|
||||
let pending_wechat_display_name = pending_wechat_identity.display_name.clone();
|
||||
|
||||
let pending_username = state
|
||||
.users_by_username
|
||||
@@ -1756,6 +1806,11 @@ impl InMemoryAuthStore {
|
||||
.find(|stored| stored.user.id == target_user_id)
|
||||
.ok_or(PhoneAuthError::UserNotFound)?;
|
||||
target_user.user.wechat_bound = true;
|
||||
target_user.user.wechat_account = Some(pending_wechat_account);
|
||||
target_user.user.wechat_display_name = pending_wechat_display_name;
|
||||
if target_user.user.phone_number.is_none() {
|
||||
target_user.user.phone_number = target_user.phone_number.clone();
|
||||
}
|
||||
let next_user = target_user.user.clone();
|
||||
self.persist_phone_state(&state)?;
|
||||
|
||||
@@ -1765,15 +1820,32 @@ impl InMemoryAuthStore {
|
||||
state
|
||||
.phone_to_user_id
|
||||
.insert(phone_number.e164.clone(), pending_user_id.to_string());
|
||||
let bound_wechat_account = state
|
||||
.wechat_identity_by_provider_uid
|
||||
.values()
|
||||
.find(|identity| identity.user_id == pending_user_id)
|
||||
.map(|identity| identity.provider_uid.clone());
|
||||
let bound_wechat_display_name = state
|
||||
.wechat_identity_by_provider_uid
|
||||
.values()
|
||||
.find(|identity| identity.user_id == pending_user_id)
|
||||
.and_then(|identity| identity.display_name.clone());
|
||||
|
||||
let stored_user = state
|
||||
.users_by_username
|
||||
.values_mut()
|
||||
.find(|stored| stored.user.id == pending_user_id)
|
||||
.ok_or(PhoneAuthError::UserNotFound)?;
|
||||
stored_user.user.phone_number = Some(phone_number.e164.clone());
|
||||
stored_user.user.phone_number_masked = Some(phone_number.masked_national_number.clone());
|
||||
stored_user.user.binding_status = AuthBindingStatus::Active;
|
||||
stored_user.user.wechat_bound = true;
|
||||
if stored_user.user.wechat_account.is_none() {
|
||||
stored_user.user.wechat_account = bound_wechat_account;
|
||||
}
|
||||
if stored_user.user.wechat_display_name.is_none() {
|
||||
stored_user.user.wechat_display_name = bound_wechat_display_name;
|
||||
}
|
||||
stored_user.phone_number = Some(phone_number.e164);
|
||||
let next_user = stored_user.user.clone();
|
||||
self.persist_phone_state(&state)?;
|
||||
@@ -3412,6 +3484,10 @@ mod tests {
|
||||
AuthBindingStatus::PendingBindPhone
|
||||
);
|
||||
assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first");
|
||||
assert_eq!(
|
||||
first_wechat.user.wechat_display_name.as_deref(),
|
||||
Some("微信旅人甲")
|
||||
);
|
||||
assert!(first_wechat.user.id.starts_with("user_"));
|
||||
assert!(!first_wechat.user.id.ends_with("00000001"));
|
||||
|
||||
@@ -3433,6 +3509,10 @@ mod tests {
|
||||
assert_ne!(second_wechat.user.id, phone_user.id);
|
||||
assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat);
|
||||
assert_eq!(second_wechat.user.username, first_wechat.user.username);
|
||||
assert_eq!(
|
||||
second_wechat.user.wechat_display_name.as_deref(),
|
||||
Some("微信旅人乙")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3482,6 +3562,10 @@ mod tests {
|
||||
wechat_user.binding_status,
|
||||
AuthBindingStatus::PendingBindPhone
|
||||
);
|
||||
assert_eq!(
|
||||
wechat_user.wechat_display_name.as_deref(),
|
||||
Some("待绑定微信用户")
|
||||
);
|
||||
assert_ne!(wechat_user.id, phone_user.id);
|
||||
|
||||
phone_service
|
||||
@@ -3509,6 +3593,10 @@ mod tests {
|
||||
assert_eq!(merged.user.id, phone_user.id);
|
||||
assert_eq!(merged.user.binding_status, AuthBindingStatus::Active);
|
||||
assert!(merged.user.wechat_bound);
|
||||
assert_eq!(
|
||||
merged.user.wechat_display_name.as_deref(),
|
||||
Some("待绑定微信用户")
|
||||
);
|
||||
|
||||
let reused_wechat_user = wechat_service
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
@@ -3526,5 +3614,9 @@ mod tests {
|
||||
assert!(!reused_wechat_user.created);
|
||||
assert_eq!(reused_wechat_user.user.id, phone_user.id);
|
||||
assert!(reused_wechat_user.user.wechat_bound);
|
||||
assert_eq!(
|
||||
reused_wechat_user.user.wechat_display_name.as_deref(),
|
||||
Some("已归并微信用户")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,61 +5,18 @@ use crate::{
|
||||
JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType,
|
||||
};
|
||||
|
||||
const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0;
|
||||
const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008;
|
||||
|
||||
pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath {
|
||||
let config = difficulty_config(difficulty);
|
||||
let mut rng = DeterministicRng::new(seed, difficulty.as_str());
|
||||
let platform_count = rng.range_u32(config.min_platforms, config.max_platforms) as usize;
|
||||
let mut platforms = Vec::with_capacity(platform_count);
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
|
||||
for index in 0..platform_count {
|
||||
let tile_type = if index == 0 {
|
||||
JumpHopTileType::Start
|
||||
} else if index + 1 == platform_count {
|
||||
JumpHopTileType::Finish
|
||||
} else if index % 7 == 0 {
|
||||
JumpHopTileType::Bonus
|
||||
} else if index % 5 == 0 {
|
||||
JumpHopTileType::Target
|
||||
} else if index % 4 == 0 {
|
||||
JumpHopTileType::Accent
|
||||
} else {
|
||||
JumpHopTileType::Normal
|
||||
};
|
||||
let width = rng.range_f32(config.min_width, config.max_width);
|
||||
let height = width * rng.range_f32(0.86, 1.04);
|
||||
let landing_radius = width * config.landing_radius_factor;
|
||||
let perfect_radius = landing_radius * config.perfect_radius_factor;
|
||||
|
||||
platforms.push(JumpHopPlatform {
|
||||
platform_id: format!("jump-hop-platform-{index:03}"),
|
||||
tile_type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
landing_radius,
|
||||
perfect_radius,
|
||||
score_value: if tile_type == JumpHopTileType::Bonus {
|
||||
180
|
||||
} else {
|
||||
100
|
||||
},
|
||||
});
|
||||
|
||||
if index + 1 < platform_count {
|
||||
let distance = rng.range_f32(config.min_gap, config.max_gap);
|
||||
let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 };
|
||||
x += distance * 0.62 * direction;
|
||||
y += distance;
|
||||
}
|
||||
}
|
||||
let platform_count = 8usize;
|
||||
let platforms = build_platforms_until(seed, difficulty, platform_count);
|
||||
|
||||
JumpHopPath {
|
||||
seed: seed.trim().to_string(),
|
||||
difficulty,
|
||||
finish_index: platform_count.saturating_sub(1) as u32,
|
||||
finish_index: u32::MAX,
|
||||
platforms,
|
||||
camera_preset: "portrait-isometric-9x16".to_string(),
|
||||
scoring: JumpHopScoring {
|
||||
@@ -85,6 +42,7 @@ pub fn start_run(
|
||||
if path.platforms.is_empty() {
|
||||
return Err(JumpHopError::EmptyPath);
|
||||
}
|
||||
let path = normalize_jump_hop_path_platform_size(path);
|
||||
|
||||
Ok(JumpHopRunSnapshot {
|
||||
run_id,
|
||||
@@ -103,7 +61,9 @@ pub fn start_run(
|
||||
|
||||
pub fn apply_jump(
|
||||
run: &JumpHopRunSnapshot,
|
||||
charge_ms: u32,
|
||||
drag_distance: f32,
|
||||
drag_vector_x: Option<f32>,
|
||||
drag_vector_y: Option<f32>,
|
||||
jumped_at_ms: u64,
|
||||
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
||||
if run.status != JumpHopRunStatus::Playing {
|
||||
@@ -111,46 +71,42 @@ pub fn apply_jump(
|
||||
}
|
||||
let current_index = run.current_platform_index as usize;
|
||||
let next_index = current_index + 1;
|
||||
let path = extend_jump_hop_path(run.path.clone(), next_index + 3);
|
||||
let current = run
|
||||
.path
|
||||
.platforms
|
||||
.get(current_index)
|
||||
.ok_or(JumpHopError::EmptyPath)?;
|
||||
let target = run
|
||||
.path
|
||||
let target = path
|
||||
.platforms
|
||||
.get(next_index)
|
||||
.ok_or(JumpHopError::NoNextPlatform)?;
|
||||
let capped_charge = charge_ms.min(run.path.scoring.max_charge_ms);
|
||||
let jump_distance = capped_charge as f32 * run.path.scoring.charge_to_distance_ratio;
|
||||
let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32);
|
||||
let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio;
|
||||
let vector_x = target.x - current.x;
|
||||
let vector_y = target.y - current.y;
|
||||
let target_distance = vector_x.hypot(vector_y).max(0.0001);
|
||||
let unit_x = vector_x / target_distance;
|
||||
let unit_y = vector_y / target_distance;
|
||||
let (unit_x, unit_y) = normalize_jump_direction(
|
||||
drag_vector_x,
|
||||
drag_vector_y,
|
||||
vector_x / target_distance,
|
||||
vector_y / target_distance,
|
||||
);
|
||||
let landed_x = current.x + unit_x * jump_distance;
|
||||
let landed_y = current.y + unit_y * jump_distance;
|
||||
let landing_error = (landed_x - target.x).hypot(landed_y - target.y);
|
||||
let target_landing_radius = target.landing_radius;
|
||||
|
||||
let mut next = run.clone();
|
||||
let result = if landing_error <= target.perfect_radius {
|
||||
if next_index as u32 == run.path.finish_index {
|
||||
JumpHopJumpResultKind::Finish
|
||||
} else {
|
||||
JumpHopJumpResultKind::Perfect
|
||||
}
|
||||
} else if landing_error <= target.landing_radius {
|
||||
if next_index as u32 == run.path.finish_index {
|
||||
JumpHopJumpResultKind::Finish
|
||||
} else {
|
||||
JumpHopJumpResultKind::Hit
|
||||
}
|
||||
next.path = path;
|
||||
let result = if landing_error <= target_landing_radius {
|
||||
JumpHopJumpResultKind::Hit
|
||||
} else {
|
||||
JumpHopJumpResultKind::Miss
|
||||
};
|
||||
|
||||
next.last_jump = Some(JumpHopLastJump {
|
||||
charge_ms: capped_charge,
|
||||
charge_ms: capped_drag_distance.round() as u32,
|
||||
jump_distance,
|
||||
target_platform_index: next_index as u32,
|
||||
landed_x,
|
||||
@@ -166,23 +122,8 @@ pub fn apply_jump(
|
||||
}
|
||||
|
||||
next.current_platform_index = next_index as u32;
|
||||
next.combo = next.combo.saturating_add(1);
|
||||
next.score = next.score.saturating_add(target.score_value);
|
||||
if matches!(
|
||||
result,
|
||||
JumpHopJumpResultKind::Perfect | JumpHopJumpResultKind::Finish
|
||||
) {
|
||||
next.score = next
|
||||
.score
|
||||
.saturating_add(run.path.scoring.perfect_bonus)
|
||||
.saturating_add(next.combo.saturating_mul(run.path.scoring.hit_bonus));
|
||||
} else {
|
||||
next.score = next.score.saturating_add(run.path.scoring.hit_bonus);
|
||||
}
|
||||
if result == JumpHopJumpResultKind::Finish {
|
||||
next.status = JumpHopRunStatus::Cleared;
|
||||
next.finished_at_ms = Some(jumped_at_ms);
|
||||
}
|
||||
next.combo = 0;
|
||||
next.score = next.current_platform_index;
|
||||
|
||||
Ok(next)
|
||||
}
|
||||
@@ -201,9 +142,31 @@ pub fn restart_run(
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_jump_hop_path_platform_size(mut path: JumpHopPath) -> JumpHopPath {
|
||||
let should_scale_legacy_path = path
|
||||
.platforms
|
||||
.iter()
|
||||
.any(|platform| platform.width < 1.2 && platform.landing_radius < 0.75);
|
||||
if !should_scale_legacy_path {
|
||||
if (path.scoring.charge_to_distance_ratio - JUMP_HOP_CHARGE_TO_DISTANCE_RATIO).abs()
|
||||
> f32::EPSILON
|
||||
{
|
||||
path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
for platform in &mut path.platforms {
|
||||
platform.width *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
||||
platform.height *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
||||
platform.landing_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
||||
platform.perfect_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
||||
}
|
||||
path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO;
|
||||
path
|
||||
}
|
||||
|
||||
struct DifficultyConfig {
|
||||
min_platforms: u32,
|
||||
max_platforms: u32,
|
||||
min_gap: f32,
|
||||
max_gap: f32,
|
||||
min_width: f32,
|
||||
@@ -214,54 +177,143 @@ struct DifficultyConfig {
|
||||
max_charge_ms: u32,
|
||||
}
|
||||
|
||||
fn build_platforms_until(
|
||||
seed: &str,
|
||||
difficulty: JumpHopDifficulty,
|
||||
required_count: usize,
|
||||
) -> Vec<JumpHopPlatform> {
|
||||
let config = difficulty_config(difficulty);
|
||||
let mut platforms = Vec::with_capacity(required_count);
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
|
||||
for index in 0..required_count {
|
||||
platforms.push(build_platform(seed, difficulty, index, x, y, &config));
|
||||
if index + 1 < required_count {
|
||||
let mut rng = DeterministicRng::new(seed, &format!("{}:{index}", difficulty.as_str()));
|
||||
let distance = rng.range_f32(config.min_gap, config.max_gap);
|
||||
let lane = rng.range_f32(0.42, 0.86);
|
||||
let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 };
|
||||
x += distance * lane * direction;
|
||||
y += distance;
|
||||
}
|
||||
}
|
||||
|
||||
platforms
|
||||
}
|
||||
|
||||
fn build_platform(
|
||||
seed: &str,
|
||||
difficulty: JumpHopDifficulty,
|
||||
index: usize,
|
||||
x: f32,
|
||||
y: f32,
|
||||
config: &DifficultyConfig,
|
||||
) -> JumpHopPlatform {
|
||||
let mut rng = DeterministicRng::new(seed, &format!("platform:{}:{index}", difficulty.as_str()));
|
||||
let tile_type = if index == 0 {
|
||||
JumpHopTileType::Start
|
||||
} else if index % 11 == 0 {
|
||||
JumpHopTileType::Bonus
|
||||
} else if index % 7 == 0 {
|
||||
JumpHopTileType::Accent
|
||||
} else if index % 3 == 0 {
|
||||
JumpHopTileType::Target
|
||||
} else {
|
||||
JumpHopTileType::Normal
|
||||
};
|
||||
let width = rng.range_f32(config.min_width, config.max_width);
|
||||
let height = width * rng.range_f32(0.88, 1.06);
|
||||
let landing_radius = width * config.landing_radius_factor;
|
||||
|
||||
JumpHopPlatform {
|
||||
platform_id: format!("jump-hop-platform-{index:05}"),
|
||||
tile_type,
|
||||
x,
|
||||
y,
|
||||
width: width * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
||||
height: height * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
||||
landing_radius: landing_radius * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
||||
perfect_radius: landing_radius
|
||||
* config.perfect_radius_factor
|
||||
* JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
||||
score_value: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHopPath {
|
||||
if path.platforms.len() >= required_count {
|
||||
return path;
|
||||
}
|
||||
path.platforms = build_platforms_until(&path.seed, path.difficulty, required_count);
|
||||
path.finish_index = u32::MAX;
|
||||
path
|
||||
}
|
||||
|
||||
fn normalize_jump_direction(
|
||||
drag_vector_x: Option<f32>,
|
||||
drag_vector_y: Option<f32>,
|
||||
fallback_x: f32,
|
||||
fallback_y: f32,
|
||||
) -> (f32, f32) {
|
||||
let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else {
|
||||
return (fallback_x, fallback_y);
|
||||
};
|
||||
let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else {
|
||||
return (fallback_x, fallback_y);
|
||||
};
|
||||
// 前端提交的是屏幕拖拽向量:x 轴同向,y 轴向下为正。
|
||||
// 真实起跳需要“反向弹出”,同时把屏幕 y 翻回世界坐标的向上为正。
|
||||
let jump_x = -drag_x;
|
||||
let jump_y = drag_y;
|
||||
let length = jump_x.hypot(jump_y);
|
||||
if length < 0.0001 {
|
||||
(fallback_x, fallback_y)
|
||||
} else {
|
||||
(jump_x / length, jump_y / length)
|
||||
}
|
||||
}
|
||||
|
||||
fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
|
||||
match difficulty {
|
||||
JumpHopDifficulty::Easy => DifficultyConfig {
|
||||
min_platforms: 12,
|
||||
max_platforms: 14,
|
||||
min_gap: 1.0,
|
||||
max_gap: 1.45,
|
||||
min_width: 0.9,
|
||||
max_width: 1.08,
|
||||
landing_radius_factor: 0.62,
|
||||
perfect_radius_factor: 0.32,
|
||||
charge_to_distance_ratio: 0.004,
|
||||
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
||||
max_charge_ms: 700,
|
||||
},
|
||||
JumpHopDifficulty::Standard => DifficultyConfig {
|
||||
min_platforms: 16,
|
||||
max_platforms: 18,
|
||||
min_gap: 1.22,
|
||||
max_gap: 1.78,
|
||||
min_width: 0.82,
|
||||
max_width: 1.0,
|
||||
landing_radius_factor: 0.54,
|
||||
perfect_radius_factor: 0.26,
|
||||
charge_to_distance_ratio: 0.004,
|
||||
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
||||
max_charge_ms: 780,
|
||||
},
|
||||
JumpHopDifficulty::Advanced => DifficultyConfig {
|
||||
min_platforms: 20,
|
||||
max_platforms: 24,
|
||||
min_gap: 1.45,
|
||||
max_gap: 2.05,
|
||||
min_width: 0.72,
|
||||
max_width: 0.94,
|
||||
landing_radius_factor: 0.48,
|
||||
perfect_radius_factor: 0.22,
|
||||
charge_to_distance_ratio: 0.004,
|
||||
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
||||
max_charge_ms: 860,
|
||||
},
|
||||
JumpHopDifficulty::Challenge => DifficultyConfig {
|
||||
min_platforms: 26,
|
||||
max_platforms: 32,
|
||||
min_gap: 1.7,
|
||||
max_gap: 2.35,
|
||||
min_width: 0.66,
|
||||
max_width: 0.88,
|
||||
landing_radius_factor: 0.42,
|
||||
perfect_radius_factor: 0.18,
|
||||
charge_to_distance_ratio: 0.004,
|
||||
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
||||
max_charge_ms: 950,
|
||||
},
|
||||
}
|
||||
@@ -289,13 +341,6 @@ impl DeterministicRng {
|
||||
(self.state >> 32) as u32
|
||||
}
|
||||
|
||||
fn range_u32(&mut self, min: u32, max: u32) -> u32 {
|
||||
if max <= min {
|
||||
return min;
|
||||
}
|
||||
min + self.next_u32() % (max - min + 1)
|
||||
}
|
||||
|
||||
fn range_f32(&mut self, min: f32, max: f32) -> f32 {
|
||||
if max <= min {
|
||||
return min;
|
||||
@@ -319,14 +364,67 @@ mod tests {
|
||||
let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge);
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!((16..=18).contains(&first.platforms.len()));
|
||||
assert!((26..=32).contains(&challenge.platforms.len()));
|
||||
assert_eq!(first.platforms.len(), 8);
|
||||
assert_eq!(challenge.platforms.len(), 8);
|
||||
assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start");
|
||||
assert_eq!(first.platforms.last().unwrap().tile_type.as_str(), "finish");
|
||||
assert_eq!(first.finish_index, u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_resolution_distinguishes_perfect_hit_and_miss() {
|
||||
fn difficulty_charge_to_distance_ratio_is_doubled() {
|
||||
let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy);
|
||||
let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard);
|
||||
let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced);
|
||||
let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge);
|
||||
|
||||
assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008);
|
||||
assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008);
|
||||
assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008);
|
||||
assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_platforms_use_double_size_and_landing_radius() {
|
||||
let path = generate_jump_hop_path("seed-size", JumpHopDifficulty::Standard);
|
||||
let first_platform = path.platforms.first().expect("platform should exist");
|
||||
|
||||
assert!(first_platform.width >= 1.64);
|
||||
assert!(first_platform.width <= 2.0);
|
||||
assert!(first_platform.height >= 1.44);
|
||||
assert!(first_platform.height <= 2.12);
|
||||
assert!(first_platform.landing_radius >= 0.88);
|
||||
assert!(first_platform.landing_radius <= 1.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_run_normalizes_legacy_single_size_platforms() {
|
||||
let mut path = generate_jump_hop_path("seed-legacy", JumpHopDifficulty::Standard);
|
||||
for platform in &mut path.platforms {
|
||||
platform.width /= 2.0;
|
||||
platform.height /= 2.0;
|
||||
platform.landing_radius /= 2.0;
|
||||
platform.perfect_radius /= 2.0;
|
||||
}
|
||||
let legacy_width = path.platforms[0].width;
|
||||
let legacy_landing_radius = path.platforms[0].landing_radius;
|
||||
|
||||
let run = start_run(
|
||||
"run-legacy".to_string(),
|
||||
"user-legacy".to_string(),
|
||||
"profile-legacy".to_string(),
|
||||
path,
|
||||
100,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
assert!((run.path.platforms[0].width - legacy_width * 2.0).abs() < 0.0001);
|
||||
assert!(
|
||||
(run.path.platforms[0].landing_radius - legacy_landing_radius * 2.0).abs() < 0.0001
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_resolution_distinguishes_hit_and_miss() {
|
||||
let path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy);
|
||||
let run = start_run(
|
||||
"run-1".to_string(),
|
||||
@@ -338,25 +436,25 @@ mod tests {
|
||||
.expect("run should start");
|
||||
let target = &run.path.platforms[1];
|
||||
let distance = target.x.hypot(target.y);
|
||||
let perfect_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32;
|
||||
|
||||
let perfect = apply_jump(&run, perfect_charge, 200).expect("jump should resolve");
|
||||
assert_eq!(
|
||||
perfect.last_jump.as_ref().unwrap().result,
|
||||
JumpHopJumpResultKind::Perfect
|
||||
);
|
||||
assert_eq!(perfect.status, JumpHopRunStatus::Playing);
|
||||
assert_eq!(perfect.current_platform_index, 1);
|
||||
let target_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32;
|
||||
|
||||
let hit =
|
||||
apply_jump(&run, perfect_charge.saturating_add(80), 200).expect("jump should resolve");
|
||||
apply_jump(&run, target_charge as f32, None, None, 200).expect("jump should resolve");
|
||||
assert_eq!(
|
||||
hit.last_jump.as_ref().unwrap().result,
|
||||
JumpHopJumpResultKind::Hit
|
||||
);
|
||||
assert_eq!(hit.status, JumpHopRunStatus::Playing);
|
||||
assert_eq!(hit.current_platform_index, 1);
|
||||
|
||||
let miss =
|
||||
apply_jump(&run, perfect_charge.saturating_add(900), 200).expect("jump should resolve");
|
||||
let miss = apply_jump(
|
||||
&run,
|
||||
target_charge.saturating_add(900) as f32,
|
||||
None,
|
||||
None,
|
||||
200,
|
||||
)
|
||||
.expect("jump should resolve");
|
||||
assert_eq!(miss.status, JumpHopRunStatus::Failed);
|
||||
assert_eq!(
|
||||
miss.last_jump.as_ref().unwrap().result,
|
||||
@@ -364,6 +462,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() {
|
||||
let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy);
|
||||
let run = start_run(
|
||||
"run-screen-axis".to_string(),
|
||||
"user-screen-axis".to_string(),
|
||||
"profile-screen-axis".to_string(),
|
||||
path,
|
||||
100,
|
||||
)
|
||||
.expect("run should start");
|
||||
let current = &run.path.platforms[0];
|
||||
let target = &run.path.platforms[1];
|
||||
let target_distance = (target.x - current.x).hypot(target.y - current.y);
|
||||
let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
|
||||
|
||||
let result = apply_jump(
|
||||
&run,
|
||||
charge as f32,
|
||||
Some(-(target.x - current.x)),
|
||||
Some(target.y - current.y),
|
||||
200,
|
||||
)
|
||||
.expect("jump should resolve");
|
||||
|
||||
assert_eq!(result.status, JumpHopRunStatus::Playing);
|
||||
assert_eq!(
|
||||
result.last_jump.as_ref().unwrap().result,
|
||||
JumpHopJumpResultKind::Hit
|
||||
);
|
||||
assert_eq!(result.current_platform_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_returns_to_first_platform_and_playing_state() {
|
||||
let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy);
|
||||
@@ -392,4 +523,32 @@ mod tests {
|
||||
assert_eq!(restarted.started_at_ms, 300);
|
||||
assert!(restarted.finished_at_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_jump_extends_infinite_path_buffer_and_counts_jumps() {
|
||||
let path = generate_jump_hop_path("seed-d", JumpHopDifficulty::Easy);
|
||||
let mut run = start_run(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
path,
|
||||
100,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
for step in 0..9 {
|
||||
let current = &run.path.platforms[run.current_platform_index as usize];
|
||||
let target = &run.path.platforms[run.current_platform_index as usize + 1];
|
||||
let distance = (target.x - current.x).hypot(target.y - current.y);
|
||||
let charge = (distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
|
||||
run = apply_jump(&run, charge as f32, None, None, 200 + step)
|
||||
.expect("jump should resolve");
|
||||
}
|
||||
|
||||
assert_eq!(run.status, JumpHopRunStatus::Playing);
|
||||
assert_eq!(run.current_platform_index, 9);
|
||||
assert_eq!(run.score, 9);
|
||||
assert!(run.path.platforms.len() >= 12);
|
||||
assert!(run.finished_at_ms.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEvent
|
||||
ends_at_text: String::new(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some(
|
||||
r#"<section style="box-sizing:border-box;width:100%;min-height:180px;padding:28px 30px;border-radius:24px;background:#fff7ed;color:#6f2f21;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"><h1 style="margin:0 0 10px;font-size:28px;">创作公告</h1><p style="margin:0;font-size:16px;line-height:1.7;">这里可以在后台替换成你的公告 HTML。</p></section>"#
|
||||
r#"<section style="box-sizing:border-box;width:100%;min-height:180px;padding:28px 30px;border-radius:24px;background:linear-gradient(90deg,rgba(255,247,237,0.96) 0%,rgba(255,247,237,0.82) 48%,rgba(255,247,237,0.18) 100%),url('/creation-type-references/puzzle.webp') center/cover no-repeat;color:#6f2f21;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"><h1 style="margin:0 0 10px;font-size:28px;">创作公告</h1><p style="margin:0;font-size:16px;line-height:1.7;">这里可以在后台替换成你的公告 HTML。</p></section>"#
|
||||
.to_string(),
|
||||
),
|
||||
}]
|
||||
@@ -233,11 +233,16 @@ pub fn resolve_creation_entry_event_banner_responses(
|
||||
event_banners_json: Option<&str>,
|
||||
fallback_banner: &CreationEntryEventBannerSnapshot,
|
||||
) -> Vec<CreationEntryEventBannerResponse> {
|
||||
event_banners_json
|
||||
let banners = event_banners_json
|
||||
.and_then(|raw| decode_creation_entry_event_banner_snapshots(raw).ok())
|
||||
.filter(|banners| !banners.is_empty())
|
||||
.unwrap_or_else(|| vec![fallback_banner.clone()])
|
||||
.into_iter()
|
||||
.unwrap_or_else(default_creation_entry_event_banner_snapshots);
|
||||
if banners.is_empty() {
|
||||
vec![fallback_banner.clone()]
|
||||
} else {
|
||||
banners
|
||||
}
|
||||
.into_iter()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
}
|
||||
@@ -399,9 +404,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
build_default_creation_entry_type_snapshot(
|
||||
"jump-hop",
|
||||
"跳一跳",
|
||||
"俯视角跳跃闯关",
|
||||
"主题驱动平台跳跃",
|
||||
"可创建",
|
||||
"/creation-type-references/puzzle.webp",
|
||||
"/creation-type-references/jump-hop.webp",
|
||||
true,
|
||||
true,
|
||||
45,
|
||||
|
||||
@@ -57,7 +57,7 @@ pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "热门推荐";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
|
||||
"/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png";
|
||||
"/creation-type-references/puzzle.webp";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
|
||||
|
||||
@@ -319,6 +319,35 @@ mod tests {
|
||||
assert_eq!(banners, default_creation_entry_event_banner_snapshots());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_none_returns_default_announcements() {
|
||||
let legacy_banner = CreationEntryEventBannerSnapshot {
|
||||
title: "旧结构化横幅".to_string(),
|
||||
description: "旧库单条字段".to_string(),
|
||||
cover_image_src:
|
||||
"/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png"
|
||||
.to_string(),
|
||||
prize_pool_mud_points: 58_000,
|
||||
starts_at_text: "2024.10.20 10:00".to_string(),
|
||||
ends_at_text: "2024.11.20 23:59".to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
};
|
||||
|
||||
let banners = resolve_creation_entry_event_banner_responses(None, &legacy_banner);
|
||||
|
||||
assert_eq!(banners.len(), 1);
|
||||
assert_eq!(banners[0].render_mode, "html");
|
||||
assert_eq!(banners[0].title, "创作公告");
|
||||
assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告"));
|
||||
assert!(banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("/creation-type-references/puzzle.webp"));
|
||||
assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banners_json_accepts_announcement_html_code() {
|
||||
let normalized = normalize_creation_entry_event_banners_json(
|
||||
@@ -433,6 +462,29 @@ mod tests {
|
||||
assert_eq!(puzzle_clear.category_id, "recommended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_include_jump_hop_theme_only_entry() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
let jump_hop = configs
|
||||
.iter()
|
||||
.find(|item| item.id == "jump-hop")
|
||||
.expect("jump-hop creation entry should be seeded");
|
||||
|
||||
assert_eq!(jump_hop.title, "跳一跳");
|
||||
assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
|
||||
assert!(jump_hop.visible);
|
||||
assert!(jump_hop.open);
|
||||
assert_eq!(jump_hop.badge, "可创建");
|
||||
assert_eq!(jump_hop.sort_order, 45);
|
||||
assert_eq!(
|
||||
jump_hop.image_src,
|
||||
"/creation-type-references/jump-hop.webp"
|
||||
);
|
||||
assert_eq!(jump_hop.category_id, "recommended");
|
||||
assert_eq!(jump_hop.category_label, "热门推荐");
|
||||
assert_eq!(jump_hop.category_sort_order, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_music_volume_into_valid_range() {
|
||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||
|
||||
@@ -6,9 +6,10 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
curl = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "net", "time"] }
|
||||
tracing = { workspace = true }
|
||||
platform-oss = { workspace = true }
|
||||
|
||||
@@ -2,13 +2,80 @@ use super::color::{
|
||||
GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE,
|
||||
GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit,
|
||||
compute_generated_asset_sheet_green_screen_score,
|
||||
compute_generated_asset_sheet_key_color_score,
|
||||
compute_generated_asset_sheet_white_screen_score,
|
||||
is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel,
|
||||
touches_generated_asset_sheet_background_mask,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GeneratedAssetSheetKeyColor {
|
||||
pub red: u8,
|
||||
pub green: u8,
|
||||
pub blue: u8,
|
||||
}
|
||||
|
||||
impl GeneratedAssetSheetKeyColor {
|
||||
pub const GREEN_SCREEN: Self = Self {
|
||||
red: 0,
|
||||
green: 255,
|
||||
blue: 0,
|
||||
};
|
||||
|
||||
pub const MAGENTA_SCREEN: Self = Self {
|
||||
red: 255,
|
||||
green: 0,
|
||||
blue: 255,
|
||||
};
|
||||
|
||||
pub fn is_green_screen(self) -> bool {
|
||||
self == Self::GREEN_SCREEN
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GeneratedAssetSheetAlphaOptions {
|
||||
pub key_color: GeneratedAssetSheetKeyColor,
|
||||
pub remove_near_white_background: bool,
|
||||
pub remove_disconnected_hard_key_background: bool,
|
||||
}
|
||||
|
||||
impl GeneratedAssetSheetAlphaOptions {
|
||||
pub const fn green_screen() -> Self {
|
||||
Self {
|
||||
key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN,
|
||||
remove_near_white_background: true,
|
||||
remove_disconnected_hard_key_background: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn jump_hop_magenta_screen() -> Self {
|
||||
Self {
|
||||
key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN,
|
||||
remove_near_white_background: false,
|
||||
remove_disconnected_hard_key_background: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GeneratedAssetSheetAlphaOptions {
|
||||
fn default() -> Self {
|
||||
Self::green_screen()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_generated_asset_sheet_green_screen_alpha(
|
||||
source: image::DynamicImage,
|
||||
) -> image::DynamicImage {
|
||||
apply_generated_asset_sheet_alpha_with_options(
|
||||
source,
|
||||
GeneratedAssetSheetAlphaOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_generated_asset_sheet_alpha_with_options(
|
||||
source: image::DynamicImage,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> image::DynamicImage {
|
||||
let mut image = source.to_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
@@ -16,6 +83,7 @@ pub fn apply_generated_asset_sheet_green_screen_alpha(
|
||||
image.as_mut(),
|
||||
width as usize,
|
||||
height as usize,
|
||||
options,
|
||||
);
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
}
|
||||
@@ -24,13 +92,14 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
pixels: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> bool {
|
||||
let pixel_count = width.saturating_mul(height);
|
||||
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut green_scores = vec![0.0f32; pixel_count];
|
||||
let mut key_scores = vec![0.0f32; pixel_count];
|
||||
let mut white_scores = vec![0.0f32; pixel_count];
|
||||
let mut background_hints = vec![0.0f32; pixel_count];
|
||||
let mut background_mask = vec![0u8; pixel_count];
|
||||
@@ -43,16 +112,19 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
let green = pixels[offset + 1];
|
||||
let blue = pixels[offset + 2];
|
||||
let alpha = pixels[offset + 3];
|
||||
let green_score =
|
||||
compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]);
|
||||
let white_score =
|
||||
compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]);
|
||||
let key_score =
|
||||
compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color);
|
||||
let white_score = if options.remove_near_white_background {
|
||||
compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha])
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let transparency_hint =
|
||||
clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75;
|
||||
|
||||
green_scores[pixel_index] = green_score;
|
||||
key_scores[pixel_index] = key_score;
|
||||
white_scores[pixel_index] = white_score;
|
||||
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
|
||||
background_hints[pixel_index] = key_score.max(white_score).max(transparency_hint);
|
||||
}
|
||||
|
||||
let seed_background_pixel =
|
||||
@@ -62,10 +134,10 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
}
|
||||
let alpha = pixels[pixel_index * 4 + 3];
|
||||
let strong_candidate = alpha < 40
|
||||
|| green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|
||||
|| key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|
||||
|| (alpha < 224
|
||||
&& green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|
||||
|| white_scores[pixel_index] > 0.32;
|
||||
&& key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|
||||
|| (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
|
||||
if !strong_candidate {
|
||||
return;
|
||||
}
|
||||
@@ -113,26 +185,34 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
}
|
||||
let next_offset = next_pixel_index * 4;
|
||||
let alpha = pixels[next_offset + 3];
|
||||
let green_score = green_scores[next_pixel_index];
|
||||
let key_score = key_scores[next_pixel_index];
|
||||
let white_score = white_scores[next_pixel_index];
|
||||
let hint = background_hints[next_pixel_index];
|
||||
let reachable_soft_edge = hint > 0.08
|
||||
&& alpha < 224
|
||||
&& (green_score > 0.04 || white_score > 0.08 || alpha < 180);
|
||||
let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|
||||
|| (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
|
||||
if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge {
|
||||
&& (key_score > 0.04
|
||||
|| (options.remove_near_white_background && white_score > 0.08)
|
||||
|| alpha < 180);
|
||||
let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|
||||
|| (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
|
||||
if alpha < 40
|
||||
|| key_background
|
||||
|| (options.remove_near_white_background && white_score > 0.32)
|
||||
|| reachable_soft_edge
|
||||
{
|
||||
background_mask[next_pixel_index] = 1;
|
||||
queue.push(next_pixel_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for pixel_index in 0..pixel_count {
|
||||
if background_mask[pixel_index] == 0
|
||||
&& green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|
||||
{
|
||||
background_mask[pixel_index] = 1;
|
||||
if options.remove_disconnected_hard_key_background {
|
||||
for pixel_index in 0..pixel_count {
|
||||
if background_mask[pixel_index] == 0
|
||||
&& key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|
||||
{
|
||||
background_mask[pixel_index] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +233,14 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
let green_score = green_scores[pixel_index];
|
||||
let key_score = key_scores[pixel_index];
|
||||
let white_score = white_scores[pixel_index];
|
||||
if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score)
|
||||
{
|
||||
if !is_generated_asset_sheet_soft_key_matte_pixel(
|
||||
pixel,
|
||||
key_score,
|
||||
white_score,
|
||||
options,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if !touches_generated_asset_sheet_background_mask(
|
||||
@@ -188,12 +272,12 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
continue;
|
||||
}
|
||||
let alpha = pixels[pixel_index * 4 + 3];
|
||||
let green_score = green_scores[pixel_index];
|
||||
let key_score = key_scores[pixel_index];
|
||||
let white_score = white_scores[pixel_index];
|
||||
let hint = background_hints[pixel_index];
|
||||
let soft_matte_candidate = alpha < 224
|
||||
|| white_score > 0.10
|
||||
|| green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
|
||||
|| (options.remove_near_white_background && white_score > 0.10)
|
||||
|| key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
|
||||
if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate {
|
||||
continue;
|
||||
}
|
||||
@@ -278,9 +362,9 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
continue;
|
||||
}
|
||||
|
||||
let green_score = green_scores[pixel_index];
|
||||
let key_score = key_scores[pixel_index];
|
||||
let white_score = white_scores[pixel_index];
|
||||
let contamination = green_score.max(white_score).max(if alpha < 220 {
|
||||
let contamination = key_score.max(white_score).max(if alpha < 220 {
|
||||
((220 - alpha) as f32 / 220.0) * 0.25
|
||||
} else {
|
||||
0.0
|
||||
@@ -301,30 +385,47 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
let mut red = pixels[offset] as f32;
|
||||
let mut green = pixels[offset + 1] as f32;
|
||||
let mut blue = pixels[offset + 2] as f32;
|
||||
let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22));
|
||||
let blend = if options.key_color.is_green_screen() {
|
||||
clamp_generated_asset_sheet_unit(contamination.max(0.22))
|
||||
} else {
|
||||
// 中文注释:洋红 / 青色等非绿幕 key 的残留更容易表现成彩边,
|
||||
// 需要比绿幕更强地向主体邻近色收敛,避免 PNG 边缘继续带 key 色。
|
||||
clamp_generated_asset_sheet_unit((key_score * 1.35).max(contamination).max(0.28))
|
||||
};
|
||||
|
||||
if let Some((sample_red, sample_green, sample_blue)) = sample {
|
||||
red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend);
|
||||
green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend);
|
||||
blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend);
|
||||
|
||||
if green_score > 0.04 {
|
||||
if options.key_color.is_green_screen() && key_score > 0.04 {
|
||||
green = green.min(sample_green as f32 + 18.0);
|
||||
}
|
||||
if white_score > 0.1 {
|
||||
if options.remove_near_white_background && white_score > 0.1 {
|
||||
red = red.min(sample_red as f32 + 26.0);
|
||||
green = green.min(sample_green as f32 + 26.0);
|
||||
blue = blue.min(sample_blue as f32 + 26.0);
|
||||
}
|
||||
if !options.key_color.is_green_screen() && key_score > 0.04 {
|
||||
let defringed = suppress_generated_asset_sheet_key_color_fringe(
|
||||
[red, green, blue],
|
||||
[sample_red as f32, sample_green as f32, sample_blue as f32],
|
||||
key_score,
|
||||
options.key_color,
|
||||
);
|
||||
red = defringed[0];
|
||||
green = defringed[1];
|
||||
blue = defringed[2];
|
||||
}
|
||||
} else {
|
||||
if green_score > 0.04 {
|
||||
if options.key_color.is_green_screen() && key_score > 0.04 {
|
||||
let toned_green = (green - (green - red.max(blue)) * 0.78)
|
||||
.round()
|
||||
.max(red.max(blue));
|
||||
green = green.min(toned_green).min(red.max(blue) + 18.0);
|
||||
}
|
||||
|
||||
if white_score > 0.12 {
|
||||
if options.remove_near_white_background && white_score > 0.12 {
|
||||
let spread = red.max(green).max(blue) - red.min(green).min(blue);
|
||||
if spread < 20.0 {
|
||||
let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
|
||||
@@ -333,10 +434,26 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
blue = blue.min(toned_value);
|
||||
}
|
||||
}
|
||||
if !options.key_color.is_green_screen() && key_score > 0.04 {
|
||||
let neutral = (red + green + blue) / 3.0;
|
||||
let defringed = suppress_generated_asset_sheet_key_color_fringe(
|
||||
[red, green, blue],
|
||||
[neutral, neutral, neutral],
|
||||
key_score,
|
||||
options.key_color,
|
||||
);
|
||||
red = defringed[0];
|
||||
green = defringed[1];
|
||||
blue = defringed[2];
|
||||
}
|
||||
}
|
||||
|
||||
let mut next_alpha = alpha;
|
||||
let edge_fade = (green_score * 0.35).max(white_score * 0.28);
|
||||
let edge_fade = if options.key_color.is_green_screen() {
|
||||
(key_score * 0.35).max(white_score * 0.28)
|
||||
} else {
|
||||
(key_score * 0.48).max(white_score * 0.28)
|
||||
};
|
||||
if edge_fade > 0.08 {
|
||||
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
|
||||
if next_alpha < 10 {
|
||||
@@ -364,6 +481,66 @@ fn remove_generated_asset_sheet_green_screen_background(
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn suppress_generated_asset_sheet_key_color_fringe(
|
||||
color: [f32; 3],
|
||||
target: [f32; 3],
|
||||
key_score: f32,
|
||||
key_color: GeneratedAssetSheetKeyColor,
|
||||
) -> [f32; 3] {
|
||||
let strength = clamp_generated_asset_sheet_unit(key_score * 1.18);
|
||||
let key_channels = [
|
||||
key_color.red as f32 / 255.0,
|
||||
key_color.green as f32 / 255.0,
|
||||
key_color.blue as f32 / 255.0,
|
||||
];
|
||||
let mut next = color;
|
||||
|
||||
for index in 0..3 {
|
||||
if key_channels[index] >= 0.66 {
|
||||
let cap = target[index] + 18.0 + (1.0 - strength) * 28.0;
|
||||
next[index] = next[index].min(lerp_generated_asset_sheet_channel(
|
||||
next[index],
|
||||
cap,
|
||||
strength,
|
||||
));
|
||||
} else if key_channels[index] <= 0.34 {
|
||||
next[index] =
|
||||
lerp_generated_asset_sheet_channel(next[index], target[index], strength * 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
fn compute_generated_asset_sheet_key_score(
|
||||
pixel: [u8; 4],
|
||||
key_color: GeneratedAssetSheetKeyColor,
|
||||
) -> f32 {
|
||||
if key_color.is_green_screen() {
|
||||
return compute_generated_asset_sheet_green_screen_score(pixel);
|
||||
}
|
||||
|
||||
compute_generated_asset_sheet_key_color_score(
|
||||
pixel,
|
||||
[key_color.red, key_color.green, key_color.blue],
|
||||
)
|
||||
}
|
||||
|
||||
fn is_generated_asset_sheet_soft_key_matte_pixel(
|
||||
pixel: [u8; 4],
|
||||
key_score: f32,
|
||||
white_score: f32,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> bool {
|
||||
if options.key_color.is_green_screen() {
|
||||
return is_generated_asset_sheet_soft_green_matte_pixel(pixel, key_score, white_score);
|
||||
}
|
||||
|
||||
pixel[3] != 0
|
||||
&& key_score >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE
|
||||
&& (!options.remove_near_white_background || white_score < 0.34)
|
||||
}
|
||||
|
||||
fn collect_generated_asset_sheet_foreground_neighbor_color(
|
||||
pixels: &[u8],
|
||||
width: usize,
|
||||
|
||||
@@ -139,6 +139,24 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -
|
||||
.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub(super) fn compute_generated_asset_sheet_key_color_score(
|
||||
pixel: [u8; 4],
|
||||
key_color: [u8; 3],
|
||||
) -> f32 {
|
||||
if pixel[3] == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let color_distance = (pixel[0] as f32 - key_color[0] as f32).abs()
|
||||
+ (pixel[1] as f32 - key_color[1] as f32).abs()
|
||||
+ (pixel[2] as f32 - key_color[2] as f32).abs();
|
||||
if color_distance >= 180.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
clamp_generated_asset_sheet_unit(1.0 - color_distance / 180.0)
|
||||
}
|
||||
|
||||
pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 {
|
||||
if pixel[3] == 0 {
|
||||
return 1.0;
|
||||
|
||||
@@ -5,7 +5,10 @@ pub mod persist;
|
||||
pub mod prompt;
|
||||
pub mod sheet;
|
||||
|
||||
pub use alpha::apply_generated_asset_sheet_green_screen_alpha;
|
||||
pub use alpha::{
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetKeyColor,
|
||||
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
|
||||
};
|
||||
pub use error::GeneratedAssetSheetError;
|
||||
pub use persist::{
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload,
|
||||
@@ -14,5 +17,6 @@ pub use persist::{
|
||||
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
|
||||
pub use sheet::{
|
||||
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
|
||||
slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
|
||||
slice_generated_asset_sheet_two_items_per_row,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use super::alpha::apply_generated_asset_sheet_green_screen_alpha;
|
||||
use super::alpha::{
|
||||
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha,
|
||||
suppress_generated_asset_sheet_key_color_fringe,
|
||||
};
|
||||
use super::color::{
|
||||
is_generated_asset_sheet_foreground_pixel,
|
||||
clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_key_color_score,
|
||||
compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel,
|
||||
is_generated_asset_sheet_green_contaminated_edge_pixel,
|
||||
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
|
||||
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
|
||||
touches_generated_asset_sheet_background_mask,
|
||||
lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask,
|
||||
};
|
||||
use super::error::GeneratedAssetSheetError;
|
||||
use image::{GenericImageView, ImageFormat};
|
||||
@@ -130,10 +134,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row(
|
||||
|
||||
pub fn crop_generated_asset_sheet_view_edge_matte(
|
||||
image: image::DynamicImage,
|
||||
) -> image::DynamicImage {
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options(
|
||||
image,
|
||||
GeneratedAssetSheetAlphaOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
|
||||
image: image::DynamicImage,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> image::DynamicImage {
|
||||
let mut image = image.to_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize);
|
||||
remove_generated_asset_sheet_view_edge_matte(
|
||||
image.as_mut(),
|
||||
width as usize,
|
||||
height as usize,
|
||||
options,
|
||||
);
|
||||
let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| {
|
||||
GeneratedAssetSheetCellBounds {
|
||||
x0: 0,
|
||||
@@ -359,6 +378,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
pixels: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> bool {
|
||||
let pixel_count = width.saturating_mul(height);
|
||||
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
|
||||
@@ -403,7 +423,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
if !is_generated_asset_sheet_view_background_pixel(pixel) {
|
||||
if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
|
||||
continue;
|
||||
}
|
||||
background_mask[pixel_index] = 1;
|
||||
@@ -434,7 +454,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
if !is_generated_asset_sheet_view_background_pixel(pixel) {
|
||||
if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
|
||||
continue;
|
||||
}
|
||||
background_mask[next_pixel_index] = 1;
|
||||
@@ -452,12 +472,15 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
continue;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
if !is_generated_asset_sheet_view_background_pixel([
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
]) {
|
||||
if !is_generated_asset_sheet_view_background_pixel_with_options(
|
||||
[
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
],
|
||||
options,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -526,7 +549,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) {
|
||||
if !is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) {
|
||||
continue;
|
||||
}
|
||||
if !touches_generated_asset_sheet_background_mask(
|
||||
@@ -539,7 +562,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_generated_asset_sheet_strong_green_contamination(pixel) {
|
||||
if is_generated_asset_sheet_strong_key_contamination(pixel, options) {
|
||||
pixels[offset] = 0;
|
||||
pixels[offset + 1] = 0;
|
||||
pixels[offset + 2] = 0;
|
||||
@@ -559,17 +582,61 @@ fn remove_generated_asset_sheet_view_edge_matte(
|
||||
y,
|
||||
&background_mask,
|
||||
&visible_mask,
|
||||
options,
|
||||
)
|
||||
.unwrap_or((
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
));
|
||||
let next_red = replacement.0.max(pixels[offset]);
|
||||
let next_blue = replacement.2.max(pixels[offset + 2]);
|
||||
let next_green = replacement
|
||||
.1
|
||||
.min(next_red.max(next_blue).saturating_add(12));
|
||||
let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() {
|
||||
let next_red = replacement.0.max(pixels[offset]);
|
||||
let next_blue = replacement.2.max(pixels[offset + 2]);
|
||||
let next_green = replacement
|
||||
.1
|
||||
.min(next_red.max(next_blue).saturating_add(12));
|
||||
(next_red, next_green, next_blue)
|
||||
} else {
|
||||
let key_score = compute_generated_asset_sheet_key_color_score(
|
||||
pixel,
|
||||
[
|
||||
options.key_color.red,
|
||||
options.key_color.green,
|
||||
options.key_color.blue,
|
||||
],
|
||||
);
|
||||
let blend = clamp_generated_asset_sheet_unit((key_score * 1.25).max(0.36));
|
||||
let red = lerp_generated_asset_sheet_channel(
|
||||
pixels[offset] as f32,
|
||||
replacement.0 as f32,
|
||||
blend,
|
||||
);
|
||||
let green = lerp_generated_asset_sheet_channel(
|
||||
pixels[offset + 1] as f32,
|
||||
replacement.1 as f32,
|
||||
blend,
|
||||
);
|
||||
let blue = lerp_generated_asset_sheet_channel(
|
||||
pixels[offset + 2] as f32,
|
||||
replacement.2 as f32,
|
||||
blend,
|
||||
);
|
||||
let defringed = suppress_generated_asset_sheet_key_color_fringe(
|
||||
[red, green, blue],
|
||||
[
|
||||
replacement.0 as f32,
|
||||
replacement.1 as f32,
|
||||
replacement.2 as f32,
|
||||
],
|
||||
key_score,
|
||||
options.key_color,
|
||||
);
|
||||
(
|
||||
defringed[0].round().clamp(0.0, 255.0) as u8,
|
||||
defringed[1].round().clamp(0.0, 255.0) as u8,
|
||||
defringed[2].round().clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
};
|
||||
if next_red != pixels[offset]
|
||||
|| next_green != pixels[offset + 1]
|
||||
|| next_blue != pixels[offset + 2]
|
||||
@@ -605,6 +672,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
|
||||
y: usize,
|
||||
background_mask: &[u8],
|
||||
visible_mask: &[u8],
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> Option<(u8, u8, u8)> {
|
||||
let mut total_weight = 0.0f32;
|
||||
let mut total_red = 0.0f32;
|
||||
@@ -638,8 +706,9 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
|
||||
pixels[next_offset + 2],
|
||||
next_alpha,
|
||||
];
|
||||
if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel)
|
||||
|| is_generated_asset_sheet_soft_edge_pixel(pixel)
|
||||
if is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options)
|
||||
|| (options.key_color.is_green_screen()
|
||||
&& is_generated_asset_sheet_soft_edge_pixel(pixel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -670,3 +739,73 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
|
||||
(total_blue / total_weight).round() as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fn is_generated_asset_sheet_view_background_pixel_with_options(
|
||||
pixel: [u8; 4],
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> bool {
|
||||
if options.key_color.is_green_screen() && options.remove_near_white_background {
|
||||
return is_generated_asset_sheet_view_background_pixel(pixel);
|
||||
}
|
||||
|
||||
if pixel[3] < 16 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if options.key_color.is_green_screen() && is_generated_asset_sheet_soft_edge_pixel(pixel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if !options.key_color.is_green_screen()
|
||||
&& compute_generated_asset_sheet_key_color_score(
|
||||
pixel,
|
||||
[
|
||||
options.key_color.red,
|
||||
options.key_color.green,
|
||||
options.key_color.blue,
|
||||
],
|
||||
) > 0.18
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
options.remove_near_white_background
|
||||
&& compute_generated_asset_sheet_white_screen_score(pixel) > 0.18
|
||||
}
|
||||
|
||||
fn is_generated_asset_sheet_key_contaminated_edge_pixel(
|
||||
pixel: [u8; 4],
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> bool {
|
||||
if options.key_color.is_green_screen() {
|
||||
return is_generated_asset_sheet_green_contaminated_edge_pixel(pixel);
|
||||
}
|
||||
|
||||
pixel[3] != 0
|
||||
&& compute_generated_asset_sheet_key_color_score(
|
||||
pixel,
|
||||
[
|
||||
options.key_color.red,
|
||||
options.key_color.green,
|
||||
options.key_color.blue,
|
||||
],
|
||||
) > 0.18
|
||||
}
|
||||
|
||||
fn is_generated_asset_sheet_strong_key_contamination(
|
||||
pixel: [u8; 4],
|
||||
options: GeneratedAssetSheetAlphaOptions,
|
||||
) -> bool {
|
||||
if options.key_color.is_green_screen() {
|
||||
return is_generated_asset_sheet_strong_green_contamination(pixel);
|
||||
}
|
||||
|
||||
compute_generated_asset_sheet_key_color_score(
|
||||
pixel,
|
||||
[
|
||||
options.key_color.red,
|
||||
options.key_color.green,
|
||||
options.key_color.blue,
|
||||
],
|
||||
) > 0.62
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use reqwest::header;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const VECTOR_ENGINE_SEND_MAX_ATTEMPTS: u32 = 5;
|
||||
const VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS: u64 = 500;
|
||||
const VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS: u64 = 999;
|
||||
|
||||
use super::{
|
||||
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
|
||||
curl_transport::{
|
||||
map_curl_error, send_vector_engine_json_request_with_curl,
|
||||
send_vector_engine_multipart_edit_request_with_curl,
|
||||
},
|
||||
error::PlatformImageError,
|
||||
image_source::resolve_reference_images,
|
||||
request::{
|
||||
build_prompt_with_negative, build_vector_engine_image_request_body, normalize_image_size,
|
||||
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body,
|
||||
normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
},
|
||||
response::handle_vector_engine_response,
|
||||
transport::map_reqwest_error,
|
||||
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
|
||||
};
|
||||
|
||||
@@ -49,61 +56,69 @@ pub async fn create_vector_engine_image_generation(
|
||||
reference_images,
|
||||
);
|
||||
let started_at = std::time::Instant::now();
|
||||
let response = match http_client
|
||||
.post(request_url.as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
let mut attempt = 1;
|
||||
let response = loop {
|
||||
match send_vector_engine_json_request_with_curl(
|
||||
request_url.as_str(),
|
||||
settings.api_key.as_str(),
|
||||
&request_body,
|
||||
settings.request_timeout_ms,
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return Err(map_reqwest_error(
|
||||
format!("{failure_context}:创建图片生成任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_images.len()),
|
||||
));
|
||||
{
|
||||
Ok(response) => break response,
|
||||
Err(error) => {
|
||||
if should_retry_vector_engine_curl_send_error(&error, attempt) {
|
||||
retry_vector_engine_send_after_delay(
|
||||
"generation",
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
attempt,
|
||||
error.is_timeout(),
|
||||
error.is_connect(),
|
||||
true,
|
||||
false,
|
||||
error.to_string().as_str(),
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_images.len()),
|
||||
Some(&request_body),
|
||||
)
|
||||
.await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(map_curl_error(
|
||||
format!("{failure_context}:创建图片生成任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_images.len()),
|
||||
Some(&request_body),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
let response_status = response.status();
|
||||
let response_status = response.status;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
status = response_status.as_u16(),
|
||||
status = response_status,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size = %normalized_size,
|
||||
reference_image_count = reference_images.len(),
|
||||
attempt,
|
||||
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||
failure_context,
|
||||
"VectorEngine 图片生成 HTTP 返回"
|
||||
);
|
||||
let response_text = match response.text().await {
|
||||
Ok(response_text) => response_text,
|
||||
Err(error) => {
|
||||
return Err(map_reqwest_error(
|
||||
format!("{failure_context}:读取图片生成响应失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"response_body",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_images.len()),
|
||||
));
|
||||
}
|
||||
};
|
||||
let response_text = response.body;
|
||||
handle_vector_engine_response(
|
||||
http_client,
|
||||
request_url.as_str(),
|
||||
response_status.as_u16(),
|
||||
response_status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
@@ -156,83 +171,110 @@ pub async fn create_vector_engine_image_edit_with_references(
|
||||
|
||||
let request_url = vector_engine_images_edit_url(settings);
|
||||
let normalized_size = normalize_image_size(size);
|
||||
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("model", GPT_IMAGE_2_MODEL.to_string())
|
||||
.text(
|
||||
"prompt",
|
||||
build_prompt_with_negative(prompt, negative_prompt),
|
||||
)
|
||||
.text("n", candidate_count.clamp(1, 4).to_string())
|
||||
.text("size", normalized_size.clone());
|
||||
|
||||
for reference_image in reference_images.iter().take(5) {
|
||||
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
|
||||
.file_name(reference_image.file_name.clone())
|
||||
.mime_str(reference_image.mime_type.as_str())
|
||||
.map_err(|error| PlatformImageError::InvalidRequest {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: format!("{failure_context}:构造参考图失败:{error}"),
|
||||
})?;
|
||||
form = form.part("image", image_part);
|
||||
}
|
||||
let request_params = build_vector_engine_image_edit_request_log_params(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
normalized_size.as_str(),
|
||||
candidate_count,
|
||||
reference_images,
|
||||
);
|
||||
|
||||
let reference_image_count = reference_images.iter().take(5).count();
|
||||
let reference_image_bytes_total: usize = reference_images
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|image| image.bytes.len())
|
||||
.sum();
|
||||
let started_at = std::time::Instant::now();
|
||||
let response = match http_client
|
||||
.post(request_url.as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return Err(map_reqwest_error(
|
||||
format!("{failure_context}:创建图片编辑任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
));
|
||||
}
|
||||
};
|
||||
let response_status = response.status();
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
status = response_status.as_u16(),
|
||||
image_model = GPT_IMAGE_2_MODEL,
|
||||
size = %normalized_size,
|
||||
candidate_count = candidate_count.clamp(1, 4),
|
||||
requested_candidate_count = candidate_count,
|
||||
prompt_chars = prompt.trim().chars().count(),
|
||||
negative_prompt_chars = negative_prompt
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::chars)
|
||||
.map(Iterator::count)
|
||||
.unwrap_or_default(),
|
||||
reference_image_count,
|
||||
reference_image_bytes_total,
|
||||
request_params = %request_params,
|
||||
failure_context,
|
||||
"VectorEngine 图片编辑请求参数"
|
||||
);
|
||||
let mut attempt = 1;
|
||||
let response = loop {
|
||||
match send_vector_engine_multipart_edit_request_with_curl(
|
||||
request_url.as_str(),
|
||||
settings.api_key.as_str(),
|
||||
prompt,
|
||||
negative_prompt,
|
||||
normalized_size.as_str(),
|
||||
candidate_count,
|
||||
reference_images,
|
||||
settings.request_timeout_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => break response,
|
||||
Err(error) => {
|
||||
if should_retry_vector_engine_curl_send_error(&error, attempt) {
|
||||
retry_vector_engine_send_after_delay(
|
||||
"edit",
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
attempt,
|
||||
error.is_timeout(),
|
||||
error.is_connect(),
|
||||
true,
|
||||
false,
|
||||
error.to_string().as_str(),
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
Some(&request_params),
|
||||
)
|
||||
.await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(map_curl_error(
|
||||
format!("{failure_context}:创建图片编辑任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"request_send",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
Some(&request_params),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
let response_status = response.status;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
status = response_status,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size = %normalized_size,
|
||||
reference_image_count,
|
||||
reference_image_bytes_total,
|
||||
request_params = %request_params,
|
||||
attempt,
|
||||
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||
failure_context,
|
||||
"VectorEngine 图片编辑 HTTP 返回"
|
||||
);
|
||||
let response_text = match response.text().await {
|
||||
Ok(response_text) => response_text,
|
||||
Err(error) => {
|
||||
return Err(map_reqwest_error(
|
||||
format!("{failure_context}:读取图片编辑响应失败").as_str(),
|
||||
request_url.as_str(),
|
||||
"response_body",
|
||||
error,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
Some(prompt.chars().count()),
|
||||
Some(reference_image_count),
|
||||
));
|
||||
}
|
||||
};
|
||||
let response_text = response.body;
|
||||
handle_vector_engine_response(
|
||||
http_client,
|
||||
request_url.as_str(),
|
||||
response_status.as_u16(),
|
||||
response_status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
@@ -243,3 +285,84 @@ pub async fn create_vector_engine_image_edit_with_references(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn should_retry_vector_engine_curl_send_error(
|
||||
error: &super::curl_transport::VectorEngineCurlError,
|
||||
attempt: u32,
|
||||
) -> bool {
|
||||
attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect())
|
||||
}
|
||||
|
||||
async fn retry_vector_engine_send_after_delay(
|
||||
request_kind: &'static str,
|
||||
request_url: &str,
|
||||
failure_stage: &'static str,
|
||||
attempt: u32,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
error: &str,
|
||||
elapsed_ms: u64,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
request_params: Option<&serde_json::Value>,
|
||||
) {
|
||||
let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms());
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
request_kind,
|
||||
failure_stage,
|
||||
attempt,
|
||||
max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS,
|
||||
retry_delay_ms = delay_ms,
|
||||
timeout,
|
||||
connect,
|
||||
request,
|
||||
body,
|
||||
status = 0,
|
||||
error,
|
||||
elapsed_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
request_params = %request_params
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
"VectorEngine 图片请求发送失败,准备重试"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 {
|
||||
let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10);
|
||||
let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS);
|
||||
VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS * exponential_factor + bounded_jitter_ms
|
||||
}
|
||||
|
||||
fn vector_engine_send_retry_jitter_ms() -> u64 {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.subsec_nanos())
|
||||
.unwrap_or_default();
|
||||
u64::from(nanos) % (VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vector_engine_send_retry_policy_allows_four_retries_before_final_attempt() {
|
||||
assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() {
|
||||
assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500);
|
||||
assert_eq!(vector_engine_send_retry_delay_ms(2, 0), 1_000);
|
||||
assert_eq!(vector_engine_send_retry_delay_ms(3, 0), 2_000);
|
||||
assert_eq!(vector_engine_send_retry_delay_ms(4, 0), 4_000);
|
||||
assert_eq!(vector_engine_send_retry_delay_ms(4, 999), 4_999);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
use std::{error::Error, fmt, time::Duration};
|
||||
|
||||
use curl::{
|
||||
FormError,
|
||||
easy::{Easy, Form, List},
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
audit::build_failure_audit,
|
||||
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
|
||||
error::PlatformImageError,
|
||||
request::build_prompt_with_negative,
|
||||
types::ReferenceImage,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct VectorEngineCurlResponse {
|
||||
pub(crate) status: u16,
|
||||
pub(crate) body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum VectorEngineCurlError {
|
||||
Curl(curl::Error),
|
||||
Form(FormError),
|
||||
WorkerJoin(tokio::task::JoinError),
|
||||
}
|
||||
|
||||
impl VectorEngineCurlError {
|
||||
pub(crate) fn is_timeout(&self) -> bool {
|
||||
match self {
|
||||
Self::Curl(error) => error.is_operation_timedout(),
|
||||
Self::Form(_) | Self::WorkerJoin(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_connect(&self) -> bool {
|
||||
match self {
|
||||
Self::Curl(error) => {
|
||||
error.is_couldnt_connect()
|
||||
|| error.is_couldnt_resolve_host()
|
||||
|| error.is_couldnt_resolve_proxy()
|
||||
}
|
||||
Self::Form(_) | Self::WorkerJoin(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VectorEngineCurlError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Curl(error) => write!(formatter, "{error}"),
|
||||
Self::Form(error) => write!(formatter, "multipart form error: {error}"),
|
||||
Self::WorkerJoin(error) => write!(formatter, "curl worker join failed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for VectorEngineCurlError {}
|
||||
|
||||
impl From<curl::Error> for VectorEngineCurlError {
|
||||
fn from(error: curl::Error) -> Self {
|
||||
Self::Curl(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FormError> for VectorEngineCurlError {
|
||||
fn from(error: FormError) -> Self {
|
||||
Self::Form(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn send_vector_engine_json_request_with_curl(
|
||||
request_url: &str,
|
||||
api_key: &str,
|
||||
request_body: &Value,
|
||||
timeout_ms: u64,
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
let request_url = request_url.to_string();
|
||||
let api_key = api_key.to_string();
|
||||
let request_body = request_body.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
send_json_request_with_curl_blocking(
|
||||
request_url.as_str(),
|
||||
api_key.as_str(),
|
||||
request_body.as_str(),
|
||||
timeout_ms,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(VectorEngineCurlError::WorkerJoin)?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
|
||||
request_url: &str,
|
||||
api_key: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
normalized_size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[ReferenceImage],
|
||||
timeout_ms: u64,
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
let request_url = request_url.to_string();
|
||||
let api_key = api_key.to_string();
|
||||
let prompt = prompt.to_string();
|
||||
let negative_prompt = negative_prompt.map(str::to_string);
|
||||
let normalized_size = normalized_size.to_string();
|
||||
let reference_images = reference_images.iter().take(5).cloned().collect::<Vec<_>>();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
send_multipart_edit_request_with_curl_blocking(
|
||||
request_url.as_str(),
|
||||
api_key.as_str(),
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_deref(),
|
||||
normalized_size.as_str(),
|
||||
candidate_count,
|
||||
reference_images.as_slice(),
|
||||
timeout_ms,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(VectorEngineCurlError::WorkerJoin)?
|
||||
}
|
||||
|
||||
pub(crate) fn map_curl_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
failure_stage: &'static str,
|
||||
error: VectorEngineCurlError,
|
||||
latency_ms: u64,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
request_params: Option<&Value>,
|
||||
) -> PlatformImageError {
|
||||
let is_timeout = error.is_timeout();
|
||||
let is_connect = error.is_connect();
|
||||
let source = error.to_string();
|
||||
let message = format!("{context}:{source}");
|
||||
let audit = build_failure_audit(
|
||||
request_url,
|
||||
context,
|
||||
failure_stage,
|
||||
None,
|
||||
None,
|
||||
is_timeout,
|
||||
is_connect,
|
||||
message.as_str(),
|
||||
Some(source.clone()),
|
||||
None,
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
failure_stage,
|
||||
timeout = is_timeout,
|
||||
connect = is_connect,
|
||||
request = true,
|
||||
body = false,
|
||||
status = 0,
|
||||
source = %source,
|
||||
source_chain = %source,
|
||||
source_chain_depth = 1,
|
||||
message = %message,
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
request_params = %request_params
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
"VectorEngine 图片 libcurl 请求失败"
|
||||
);
|
||||
|
||||
PlatformImageError::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
endpoint: Some(request_url.to_string()),
|
||||
timeout: is_timeout,
|
||||
connect: is_connect,
|
||||
request: true,
|
||||
body: false,
|
||||
status_code: None,
|
||||
source: Some(source),
|
||||
audit: Some(audit),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_json_request_with_curl_blocking(
|
||||
request_url: &str,
|
||||
api_key: &str,
|
||||
request_body: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
let mut headers = vector_engine_curl_headers(api_key)?;
|
||||
headers.append("Content-Type: application/json")?;
|
||||
let mut easy = Easy::new();
|
||||
easy.url(request_url)?;
|
||||
easy.post(true)?;
|
||||
easy.http_headers(headers)?;
|
||||
easy.timeout(Duration::from_millis(timeout_ms.max(1)))?;
|
||||
easy.post_fields_copy(request_body.as_bytes())?;
|
||||
Ok(perform_curl_request(easy)?)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn send_multipart_edit_request_with_curl_blocking(
|
||||
request_url: &str,
|
||||
api_key: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
normalized_size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[ReferenceImage],
|
||||
timeout_ms: u64,
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
let mut form = Form::new();
|
||||
form.part("model")
|
||||
.contents(GPT_IMAGE_2_MODEL.as_bytes())
|
||||
.add()?;
|
||||
form.part("prompt")
|
||||
.contents(build_prompt_with_negative(prompt, negative_prompt).as_bytes())
|
||||
.add()?;
|
||||
form.part("n")
|
||||
.contents(candidate_count.clamp(1, 4).to_string().as_bytes())
|
||||
.add()?;
|
||||
form.part("size")
|
||||
.contents(normalized_size.as_bytes())
|
||||
.add()?;
|
||||
|
||||
for reference_image in reference_images {
|
||||
form.part("image")
|
||||
.buffer(
|
||||
reference_image.file_name.as_str(),
|
||||
reference_image.bytes.clone(),
|
||||
)
|
||||
.content_type(reference_image.mime_type.as_str())
|
||||
.add()?;
|
||||
}
|
||||
|
||||
let headers = vector_engine_curl_headers(api_key)?;
|
||||
let mut easy = Easy::new();
|
||||
easy.url(request_url)?;
|
||||
easy.httppost(form)?;
|
||||
easy.http_headers(headers)?;
|
||||
easy.timeout(Duration::from_millis(timeout_ms.max(1)))?;
|
||||
Ok(perform_curl_request(easy)?)
|
||||
}
|
||||
|
||||
fn vector_engine_curl_headers(api_key: &str) -> Result<List, curl::Error> {
|
||||
let mut headers = List::new();
|
||||
headers.append(format!("Authorization: Bearer {api_key}").as_str())?;
|
||||
headers.append("Accept: application/json")?;
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn perform_curl_request(mut easy: Easy) -> Result<VectorEngineCurlResponse, curl::Error> {
|
||||
let mut body = Vec::new();
|
||||
{
|
||||
let mut transfer = easy.transfer();
|
||||
transfer.write_function(|data| {
|
||||
body.extend_from_slice(data);
|
||||
Ok(data.len())
|
||||
})?;
|
||||
transfer.perform()?;
|
||||
}
|
||||
let status = easy.response_code()? as u16;
|
||||
let body = String::from_utf8_lossy(body.as_slice()).into_owned();
|
||||
Ok(VectorEngineCurlResponse { status, body })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vector_engine::types::ReferenceImage;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
sync::oneshot,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_curl_transport_posts_json_request() {
|
||||
let (base_url, server, request_rx) = start_single_response_server().await;
|
||||
let response = send_vector_engine_json_request_with_curl(
|
||||
format!("{base_url}/v1/images/generations").as_str(),
|
||||
"test-key",
|
||||
&serde_json::json!({"model":"gpt-image-2","prompt":"测试"}),
|
||||
1_000,
|
||||
)
|
||||
.await
|
||||
.expect("curl json request should succeed");
|
||||
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body, "{\"data\":[]}");
|
||||
let request = request_rx
|
||||
.await
|
||||
.expect("mock server should capture request");
|
||||
let request_text = String::from_utf8_lossy(request.as_slice());
|
||||
assert!(request_text.contains("Content-Type: application/json"));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_curl_transport_posts_multipart_request() {
|
||||
let (base_url, server, request_rx) = start_single_response_server().await;
|
||||
let response = send_vector_engine_multipart_edit_request_with_curl(
|
||||
format!("{base_url}/v1/images/edits").as_str(),
|
||||
"test-key",
|
||||
"测试提示词",
|
||||
None,
|
||||
"1024x1024",
|
||||
1,
|
||||
&[ReferenceImage {
|
||||
bytes: b"reference".to_vec(),
|
||||
mime_type: "image/png".to_string(),
|
||||
file_name: "reference.png".to_string(),
|
||||
}],
|
||||
1_000,
|
||||
)
|
||||
.await
|
||||
.expect("curl multipart request should succeed");
|
||||
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body, "{\"data\":[]}");
|
||||
let request = request_rx
|
||||
.await
|
||||
.expect("mock server should capture request");
|
||||
let request_text = String::from_utf8_lossy(request.as_slice());
|
||||
assert!(request_text.contains("name=\"image\"; filename=\"reference.png\""));
|
||||
assert!(request_text.contains("Content-Type: image/png"));
|
||||
assert!(request_text.contains("reference"));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
async fn start_single_response_server() -> (
|
||||
String,
|
||||
tokio::task::JoinHandle<()>,
|
||||
oneshot::Receiver<Vec<u8>>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("mock server should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("mock server addr should be readable");
|
||||
let (request_tx, request_rx) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let Ok(read) = stream.read(&mut buffer).await else {
|
||||
return;
|
||||
};
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let header_end = request
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
.unwrap_or(request.len());
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("Content-Length:")
|
||||
.or_else(|| line.strip_prefix("content-length:"))
|
||||
})
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or_default();
|
||||
let expected_len = header_end + content_length;
|
||||
while request.len() < expected_len {
|
||||
let Ok(read) = stream.read(&mut buffer).await else {
|
||||
return;
|
||||
};
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
let _ = request_tx.send(request);
|
||||
let body = "{\"data\":[]}";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
});
|
||||
|
||||
(format!("http://{addr}"), server, request_rx)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod audit;
|
||||
mod client;
|
||||
mod constants;
|
||||
mod curl_transport;
|
||||
mod error;
|
||||
mod image_source;
|
||||
mod payload;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{constants::GPT_IMAGE_2_MODEL, types::VectorEngineImageSettings};
|
||||
use super::{
|
||||
constants::GPT_IMAGE_2_MODEL,
|
||||
types::{ReferenceImage, VectorEngineImageSettings},
|
||||
};
|
||||
|
||||
pub fn build_vector_engine_image_request_body(
|
||||
prompt: &str,
|
||||
@@ -56,6 +59,52 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_vector_engine_image_edit_request_log_params(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[ReferenceImage],
|
||||
) -> Value {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let references: Vec<Value> = reference_images
|
||||
.iter()
|
||||
.take(5)
|
||||
.enumerate()
|
||||
.map(|(index, image)| {
|
||||
json!({
|
||||
"index": index,
|
||||
"field": "image",
|
||||
"fileName": image.file_name.as_str(),
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"bytes": image.bytes.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let reference_image_bytes_total: usize = reference_images
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|image| image.bytes.len())
|
||||
.sum();
|
||||
|
||||
json!({
|
||||
"model": GPT_IMAGE_2_MODEL,
|
||||
"prompt": prompt,
|
||||
"negativePrompt": negative_prompt.unwrap_or_default(),
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"n": candidate_count.clamp(1, 4),
|
||||
"requestedCandidateCount": candidate_count,
|
||||
"size": size,
|
||||
"referenceImageCount": references.len(),
|
||||
"referenceImageBytesTotal": reference_image_bytes_total,
|
||||
"referenceImages": references,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let Some(negative_prompt) = negative_prompt
|
||||
@@ -67,3 +116,49 @@ pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&
|
||||
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vector_engine::types::ReferenceImage;
|
||||
|
||||
#[test]
|
||||
fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() {
|
||||
let params = build_vector_engine_image_edit_request_log_params(
|
||||
" 拼图参考图重绘 ",
|
||||
Some(" 文字,水印 "),
|
||||
"1024x1024",
|
||||
9,
|
||||
&[
|
||||
ReferenceImage {
|
||||
bytes: vec![1, 2, 3, 4, 5],
|
||||
mime_type: "image/png".to_string(),
|
||||
file_name: "reference-a.png".to_string(),
|
||||
},
|
||||
ReferenceImage {
|
||||
bytes: vec![8; 7],
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
file_name: "reference-b.jpg".to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(params["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(params["prompt"], "拼图参考图重绘");
|
||||
assert_eq!(params["negativePrompt"], "文字,水印");
|
||||
assert_eq!(params["n"], 4);
|
||||
assert_eq!(params["requestedCandidateCount"], 9);
|
||||
assert_eq!(params["size"], "1024x1024");
|
||||
assert_eq!(params["referenceImageCount"], 2);
|
||||
assert_eq!(params["referenceImageBytesTotal"], 12);
|
||||
assert_eq!(params["referenceImages"][0]["field"], "image");
|
||||
assert_eq!(params["referenceImages"][0]["fileName"], "reference-a.png");
|
||||
assert_eq!(params["referenceImages"][0]["mimeType"], "image/png");
|
||||
assert_eq!(params["referenceImages"][0]["bytes"], 5);
|
||||
|
||||
let serialized = params.to_string();
|
||||
assert!(!serialized.contains("api_key"));
|
||||
assert!(!serialized.contains("Bearer"));
|
||||
assert!(!serialized.contains("[1,2,3,4,5]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::{error::Error, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{
|
||||
audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError,
|
||||
types::VectorEngineImageSettings,
|
||||
constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, types::VectorEngineImageSettings,
|
||||
};
|
||||
|
||||
pub fn build_vector_engine_image_http_client(
|
||||
@@ -18,126 +17,3 @@ pub fn build_vector_engine_image_http_client(
|
||||
message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_reqwest_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
failure_stage: &'static str,
|
||||
error: reqwest::Error,
|
||||
latency_ms: u64,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
) -> PlatformImageError {
|
||||
let is_timeout = error.is_timeout();
|
||||
let is_connect = error.is_connect();
|
||||
let source_chain_parts = collect_error_source_chain(&error);
|
||||
let source = source_chain_parts.first().cloned();
|
||||
let source_chain_depth = source_chain_parts.len();
|
||||
let source_chain = if source_chain_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(source_chain_parts.join(" -> "))
|
||||
};
|
||||
let message = format!("{context}:{error}");
|
||||
let audit = build_failure_audit(
|
||||
request_url,
|
||||
context,
|
||||
failure_stage,
|
||||
error.status().map(|status| status.as_u16()),
|
||||
None,
|
||||
is_timeout,
|
||||
is_connect,
|
||||
message.as_str(),
|
||||
source_chain.clone().or_else(|| source.clone()),
|
||||
None,
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
failure_stage,
|
||||
timeout = is_timeout,
|
||||
connect = is_connect,
|
||||
request = error.is_request(),
|
||||
body = error.is_body(),
|
||||
status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
|
||||
source = %source.clone().unwrap_or_default(),
|
||||
source_chain = %source_chain.clone().unwrap_or_default(),
|
||||
source_chain_depth,
|
||||
message = %message,
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
"VectorEngine 图片请求发送失败"
|
||||
);
|
||||
|
||||
PlatformImageError::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
endpoint: Some(request_url.to_string()),
|
||||
timeout: is_timeout,
|
||||
connect: is_connect,
|
||||
request: error.is_request(),
|
||||
body: error.is_body(),
|
||||
status_code: error.status().map(|status| status.as_u16()),
|
||||
source: source_chain.or(source),
|
||||
audit: Some(audit),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec<String> {
|
||||
let mut chain = Vec::new();
|
||||
let mut next = error.source();
|
||||
while let Some(source) = next {
|
||||
chain.push(source.to_string());
|
||||
next = source.source();
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestError {
|
||||
message: &'static str,
|
||||
source: Option<Box<TestError>>,
|
||||
}
|
||||
|
||||
impl fmt::Display for TestError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TestError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
self.source
|
||||
.as_deref()
|
||||
.map(|source| source as &(dyn Error + 'static))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_error_source_chain_keeps_nested_causes() {
|
||||
let error = TestError {
|
||||
message: "top",
|
||||
source: Some(Box::new(TestError {
|
||||
message: "middle",
|
||||
source: Some(Box::new(TestError {
|
||||
message: "bottom",
|
||||
source: None,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
collect_error_source_chain(&error),
|
||||
vec!["middle".to_string(), "bottom".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
use platform_image::DownloadedImage;
|
||||
use platform_image::generated_asset_sheets::{
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha,
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetPersistInput,
|
||||
GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput,
|
||||
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
|
||||
build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet,
|
||||
slice_generated_asset_sheet_two_items_per_row,
|
||||
};
|
||||
@@ -142,6 +144,140 @@ fn generated_asset_sheet_green_screen_alpha_removes_green_background() {
|
||||
assert_eq!(cleaned.get_pixel(10, 10).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_sheet_magenta_key_preserves_green_white_and_disconnected_key_subject() {
|
||||
let mut sheet = RgbaImage::from_pixel(28, 28, Rgba([255, 0, 255, 255]));
|
||||
for y in 6..22 {
|
||||
for x in 6..14 {
|
||||
sheet.put_pixel(x, y, Rgba([64, 188, 74, 255]));
|
||||
}
|
||||
}
|
||||
for y in 6..22 {
|
||||
for x in 14..22 {
|
||||
sheet.put_pixel(x, y, Rgba([244, 244, 236, 255]));
|
||||
}
|
||||
}
|
||||
for y in 12..16 {
|
||||
for x in 12..16 {
|
||||
sheet.put_pixel(x, y, Rgba([255, 0, 255, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let cleaned = apply_generated_asset_sheet_alpha_with_options(
|
||||
DynamicImage::ImageRgba8(sheet),
|
||||
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
|
||||
)
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(cleaned.get_pixel(8, 8).0[3], 255);
|
||||
assert_eq!(cleaned.get_pixel(18, 8).0[3], 255);
|
||||
assert_eq!(
|
||||
cleaned.get_pixel(13, 13).0[3],
|
||||
255,
|
||||
"非边缘连通的 key 色像素不应被当成背景清掉"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() {
|
||||
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([0, 0, 0, 0]));
|
||||
for y in 2..22 {
|
||||
for x in 2..22 {
|
||||
sheet.put_pixel(x, y, Rgba([246, 246, 240, 255]));
|
||||
}
|
||||
}
|
||||
for y in 0..24 {
|
||||
sheet.put_pixel(0, y, Rgba([255, 0, 255, 255]));
|
||||
sheet.put_pixel(23, y, Rgba([255, 0, 255, 255]));
|
||||
}
|
||||
|
||||
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
|
||||
DynamicImage::ImageRgba8(sheet),
|
||||
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
|
||||
)
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(cleaned.get_pixel(1, 1).0[3], 255);
|
||||
assert!(
|
||||
cleaned
|
||||
.pixels()
|
||||
.any(|pixel| pixel.0 == [246, 246, 240, 255])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_sheet_magenta_alpha_defringes_pink_halo() {
|
||||
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([255, 0, 255, 255]));
|
||||
for y in 7..17 {
|
||||
for x in 7..17 {
|
||||
sheet.put_pixel(x, y, Rgba([198, 170, 120, 255]));
|
||||
}
|
||||
}
|
||||
for y in 6..18 {
|
||||
sheet.put_pixel(6, y, Rgba([226, 26, 218, 220]));
|
||||
sheet.put_pixel(17, y, Rgba([226, 26, 218, 220]));
|
||||
}
|
||||
for x in 6..18 {
|
||||
sheet.put_pixel(x, 6, Rgba([226, 26, 218, 220]));
|
||||
sheet.put_pixel(x, 17, Rgba([226, 26, 218, 220]));
|
||||
}
|
||||
|
||||
let cleaned = apply_generated_asset_sheet_alpha_with_options(
|
||||
DynamicImage::ImageRgba8(sheet),
|
||||
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
|
||||
)
|
||||
.to_rgba8();
|
||||
let edge = cleaned.get_pixel(6, 12).0;
|
||||
|
||||
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(cleaned.get_pixel(12, 12).0, [198, 170, 120, 255]);
|
||||
if edge[3] > 0 {
|
||||
assert!(
|
||||
edge[0].saturating_sub(edge[1]) <= 76,
|
||||
"红色 key 通道残留过强:{edge:?}"
|
||||
);
|
||||
assert!(
|
||||
edge[2].saturating_sub(edge[1]) <= 76,
|
||||
"蓝色 key 通道残留过强:{edge:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_sheet_magenta_edge_matte_defringes_bottom_shadow() {
|
||||
let mut sheet = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0]));
|
||||
for y in 8..18 {
|
||||
for x in 10..22 {
|
||||
sheet.put_pixel(x, y, Rgba([202, 176, 126, 255]));
|
||||
}
|
||||
}
|
||||
for y in 18..22 {
|
||||
for x in 9..23 {
|
||||
sheet.put_pixel(x, y, Rgba([224, 30, 220, 186]));
|
||||
}
|
||||
}
|
||||
|
||||
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
|
||||
DynamicImage::ImageRgba8(sheet),
|
||||
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
|
||||
)
|
||||
.to_rgba8();
|
||||
|
||||
assert!(
|
||||
cleaned
|
||||
.pixels()
|
||||
.any(|pixel| pixel.0 == [202, 176, 126, 255])
|
||||
);
|
||||
assert!(
|
||||
!cleaned.pixels().any(|pixel| {
|
||||
let [red, green, blue, alpha] = pixel.0;
|
||||
alpha > 0 && red > 200 && blue > 200 && green < 96
|
||||
}),
|
||||
"底部洋红残影应被删除或去彩边"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_sheet_view_edge_matte_trims_transparent_border() {
|
||||
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
use platform_image::vector_engine::{
|
||||
GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_request_body, vector_engine_images_edit_url,
|
||||
GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
|
||||
create_vector_engine_image_edit, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn vector_engine_module_exposes_provider_protocol_helpers() {
|
||||
@@ -30,3 +42,70 @@ fn vector_engine_module_exposes_provider_protocol_helpers() {
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("mock server should bind");
|
||||
let server_addr = listener
|
||||
.local_addr()
|
||||
.expect("mock server address should be readable");
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let request_count_for_server = Arc::clone(&request_count);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).await;
|
||||
if request_index == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let settings = VectorEngineImageSettings {
|
||||
base_url: format!("http://{server_addr}/v1"),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 40,
|
||||
};
|
||||
let http_client =
|
||||
build_vector_engine_image_http_client(&settings).expect("client should build");
|
||||
let reference_image = ReferenceImage {
|
||||
bytes: b"reference".to_vec(),
|
||||
mime_type: "image/png".to_string(),
|
||||
file_name: "reference.png".to_string(),
|
||||
};
|
||||
|
||||
let generated = create_vector_engine_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
"测试提示词",
|
||||
None,
|
||||
"1024x1024",
|
||||
&reference_image,
|
||||
"测试 VectorEngine 图片编辑失败",
|
||||
)
|
||||
.await
|
||||
.expect("second attempt should return generated image");
|
||||
|
||||
assert_eq!(generated.images.len(), 1);
|
||||
assert_eq!(generated.images[0].mime_type, "image/png");
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
5. 服务端 `PutObject` 上传 helper
|
||||
6. `x-oss-meta-*` 元数据归一化与大小限制校验
|
||||
7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成
|
||||
8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志
|
||||
|
||||
当前仍未落地的内容:
|
||||
|
||||
@@ -34,8 +35,9 @@
|
||||
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
|
||||
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。
|
||||
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。
|
||||
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
|
||||
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
|
||||
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
|
||||
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
|
||||
6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::BTreeMap, error::Error, fmt};
|
||||
use std::{collections::BTreeMap, error::Error, fmt, time::Instant};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use hmac::{Hmac, Mac};
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, warn};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -19,6 +20,7 @@ const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
|
||||
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
||||
const OSS_V4_SERVICE: &str = "oss";
|
||||
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
const OSS_PROVIDER: &str = "aliyun-oss";
|
||||
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 14] = [
|
||||
"generated-character-drafts",
|
||||
@@ -373,105 +375,154 @@ impl OssClient {
|
||||
&self,
|
||||
request: OssPostObjectRequest,
|
||||
) -> Result<OssPostObjectResponse, OssError> {
|
||||
let max_size_bytes = request
|
||||
.max_size_bytes
|
||||
.unwrap_or(self.config.default_post_max_size_bytes);
|
||||
let expire_seconds = request
|
||||
.expire_seconds
|
||||
.unwrap_or(self.config.default_post_expire_seconds);
|
||||
let success_action_status = request
|
||||
.success_action_status
|
||||
.unwrap_or(self.config.default_success_action_status);
|
||||
let started_at = Instant::now();
|
||||
let requested_prefix = request.prefix.as_str();
|
||||
let requested_content_type = request
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let requested_metadata_count = request.metadata.len();
|
||||
|
||||
if max_size_bytes == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"maxSizeBytes 必须大于 0".to_string(),
|
||||
));
|
||||
let result = (|| {
|
||||
let max_size_bytes = request
|
||||
.max_size_bytes
|
||||
.unwrap_or(self.config.default_post_max_size_bytes);
|
||||
let expire_seconds = request
|
||||
.expire_seconds
|
||||
.unwrap_or(self.config.default_post_expire_seconds);
|
||||
let success_action_status = request
|
||||
.success_action_status
|
||||
.unwrap_or(self.config.default_success_action_status);
|
||||
|
||||
if max_size_bytes == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"maxSizeBytes 必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if expire_seconds == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"expireSeconds 必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !(100..=999).contains(&success_action_status) {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"successActionStatus 必须是三位 HTTP 状态码".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_segments = request
|
||||
.path_segments
|
||||
.iter()
|
||||
.map(|segment| sanitize_path_segment(segment))
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let file_name = sanitize_file_name(&request.file_name)?;
|
||||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||||
let legacy_public_path = format!("/{}", object_key);
|
||||
let content_type = normalize_optional_value(request.content_type);
|
||||
let metadata = normalize_metadata(request.metadata)?;
|
||||
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
||||
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
|
||||
)?))
|
||||
.ok_or_else(|| {
|
||||
OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string())
|
||||
})?;
|
||||
let expires_at = expires_at.format(&Rfc3339).map_err(|error| {
|
||||
OssError::SerializePolicy(format!("格式化过期时间失败:{error}"))
|
||||
})?;
|
||||
|
||||
let signed_at = OffsetDateTime::now_utc();
|
||||
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
|
||||
let signature_date = build_v4_signature_date(signed_at)?;
|
||||
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
|
||||
let policy_json = build_policy_json(
|
||||
&self.config.bucket,
|
||||
&object_key,
|
||||
&expires_at,
|
||||
max_size_bytes,
|
||||
success_action_status,
|
||||
content_type.as_deref(),
|
||||
&metadata,
|
||||
&credential,
|
||||
&signature_date,
|
||||
);
|
||||
let policy = serde_json::to_string(&policy_json).map_err(|error| {
|
||||
OssError::SerializePolicy(format!("序列化 policy 失败:{error}"))
|
||||
})?;
|
||||
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
|
||||
let signature = sign_v4_content(
|
||||
&self.config.access_key_secret,
|
||||
&signature_scope,
|
||||
&encoded_policy,
|
||||
)?;
|
||||
|
||||
Ok(OssPostObjectResponse {
|
||||
signature_version: "v4",
|
||||
provider: OSS_PROVIDER,
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
object_key: object_key.clone(),
|
||||
legacy_public_path,
|
||||
content_type: content_type.clone(),
|
||||
access: request.access,
|
||||
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
|
||||
expires_at,
|
||||
max_size_bytes,
|
||||
success_action_status,
|
||||
form_fields: OssPostObjectFormFields {
|
||||
key: object_key,
|
||||
policy: encoded_policy,
|
||||
signature_version: OSS_V4_ALGORITHM.to_string(),
|
||||
credential,
|
||||
date: signature_date,
|
||||
signature,
|
||||
success_action_status: success_action_status.to_string(),
|
||||
content_type,
|
||||
metadata,
|
||||
},
|
||||
})
|
||||
})();
|
||||
|
||||
match &result {
|
||||
Ok(response) => info!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "sign_post_object",
|
||||
bucket = %response.bucket,
|
||||
endpoint = %response.endpoint,
|
||||
object_key = %response.object_key,
|
||||
key_prefix = %response.key_prefix,
|
||||
access = oss_access_label(response.access),
|
||||
content_type = %response.content_type.as_deref().unwrap_or(""),
|
||||
max_size_bytes = response.max_size_bytes,
|
||||
success_action_status = response.success_action_status,
|
||||
metadata_count = response.form_fields.metadata.len(),
|
||||
expires_at = %response.expires_at,
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS PostObject 签名完成"
|
||||
),
|
||||
Err(error) => warn!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "sign_post_object",
|
||||
bucket = %self.config.bucket(),
|
||||
endpoint = %self.config.endpoint(),
|
||||
key_prefix = requested_prefix,
|
||||
content_type = %requested_content_type,
|
||||
metadata_count = requested_metadata_count,
|
||||
error_kind = oss_error_kind_label(error),
|
||||
message = %error,
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS PostObject 签名失败"
|
||||
),
|
||||
}
|
||||
|
||||
if expire_seconds == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"expireSeconds 必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !(100..=999).contains(&success_action_status) {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"successActionStatus 必须是三位 HTTP 状态码".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_segments = request
|
||||
.path_segments
|
||||
.iter()
|
||||
.map(|segment| sanitize_path_segment(segment))
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let file_name = sanitize_file_name(&request.file_name)?;
|
||||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||||
let legacy_public_path = format!("/{}", object_key);
|
||||
let content_type = normalize_optional_value(request.content_type);
|
||||
let metadata = normalize_metadata(request.metadata)?;
|
||||
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
||||
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
|
||||
)?))
|
||||
.ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
|
||||
let expires_at = expires_at
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?;
|
||||
|
||||
let signed_at = OffsetDateTime::now_utc();
|
||||
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
|
||||
let signature_date = build_v4_signature_date(signed_at)?;
|
||||
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
|
||||
let policy_json = build_policy_json(
|
||||
&self.config.bucket,
|
||||
&object_key,
|
||||
&expires_at,
|
||||
max_size_bytes,
|
||||
success_action_status,
|
||||
content_type.as_deref(),
|
||||
&metadata,
|
||||
&credential,
|
||||
&signature_date,
|
||||
);
|
||||
let policy = serde_json::to_string(&policy_json)
|
||||
.map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
|
||||
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
|
||||
let signature = sign_v4_content(
|
||||
&self.config.access_key_secret,
|
||||
&signature_scope,
|
||||
&encoded_policy,
|
||||
)?;
|
||||
|
||||
Ok(OssPostObjectResponse {
|
||||
signature_version: "v4",
|
||||
provider: "aliyun-oss",
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
object_key: object_key.clone(),
|
||||
legacy_public_path,
|
||||
content_type: content_type.clone(),
|
||||
access: request.access,
|
||||
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
|
||||
expires_at,
|
||||
max_size_bytes,
|
||||
success_action_status,
|
||||
form_fields: OssPostObjectFormFields {
|
||||
key: object_key,
|
||||
policy: encoded_policy,
|
||||
signature_version: OSS_V4_ALGORITHM.to_string(),
|
||||
credential,
|
||||
date: signature_date,
|
||||
signature,
|
||||
success_action_status: success_action_status.to_string(),
|
||||
content_type,
|
||||
metadata,
|
||||
},
|
||||
})
|
||||
result
|
||||
}
|
||||
|
||||
// 私有 bucket 的对象读取统一走短期签名 URL,避免把长期主凭证下发给浏览器。
|
||||
@@ -479,81 +530,119 @@ impl OssClient {
|
||||
&self,
|
||||
request: OssSignedGetObjectUrlRequest,
|
||||
) -> Result<OssSignedGetObjectUrlResponse, OssError> {
|
||||
let expire_seconds = request
|
||||
.expire_seconds
|
||||
.unwrap_or(self.config.default_read_expire_seconds);
|
||||
let started_at = Instant::now();
|
||||
let requested_object_key = request
|
||||
.object_key
|
||||
.trim()
|
||||
.trim_start_matches('/')
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if expire_seconds == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"expireSeconds 必须大于 0".to_string(),
|
||||
));
|
||||
let result = (|| {
|
||||
let expire_seconds = request
|
||||
.expire_seconds
|
||||
.unwrap_or(self.config.default_read_expire_seconds);
|
||||
|
||||
if expire_seconds == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"expireSeconds 必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let object_key = normalize_object_key(&request.object_key)?;
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
||||
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
|
||||
)?))
|
||||
.ok_or_else(|| {
|
||||
OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string())
|
||||
})?;
|
||||
let expires_at_text = expires_at
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
|
||||
|
||||
let signed_at = OffsetDateTime::now_utc();
|
||||
let signed_at_text = build_v4_signature_date(signed_at)?;
|
||||
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
|
||||
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
|
||||
let mut query = BTreeMap::from([
|
||||
("x-oss-additional-headers".to_string(), "host".to_string()),
|
||||
(
|
||||
"x-oss-signature-version".to_string(),
|
||||
OSS_V4_ALGORITHM.to_string(),
|
||||
),
|
||||
("x-oss-credential".to_string(), credential),
|
||||
("x-oss-date".to_string(), signed_at_text),
|
||||
("x-oss-expires".to_string(), expire_seconds.to_string()),
|
||||
]);
|
||||
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
|
||||
let object_url_path = format!("/{}", encode_url_path(&object_key));
|
||||
let additional_headers = "host";
|
||||
let canonical_headers =
|
||||
format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
|
||||
let canonical_query = build_canonical_query_string(&query);
|
||||
let canonical_request = build_v4_canonical_request(
|
||||
Method::GET.as_str(),
|
||||
&canonical_uri,
|
||||
&canonical_query,
|
||||
&canonical_headers,
|
||||
additional_headers,
|
||||
OSS_UNSIGNED_PAYLOAD,
|
||||
);
|
||||
let string_to_sign = build_v4_string_to_sign(
|
||||
query["x-oss-date"].as_str(),
|
||||
&signature_scope,
|
||||
&canonical_request,
|
||||
);
|
||||
let signature = sign_v4_content(
|
||||
&self.config.access_key_secret,
|
||||
&signature_scope,
|
||||
&string_to_sign,
|
||||
)?;
|
||||
query.insert("x-oss-signature".to_string(), signature);
|
||||
let signed_url = format!(
|
||||
"{}{}?{}",
|
||||
self.config.upload_host(),
|
||||
object_url_path,
|
||||
build_canonical_query_string(&query)
|
||||
);
|
||||
|
||||
Ok(OssSignedGetObjectUrlResponse {
|
||||
provider: OSS_PROVIDER,
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
object_key,
|
||||
expires_at: expires_at_text,
|
||||
signed_url,
|
||||
})
|
||||
})();
|
||||
|
||||
match &result {
|
||||
Ok(response) => info!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "sign_get_object_url",
|
||||
bucket = %response.bucket,
|
||||
endpoint = %response.endpoint,
|
||||
object_key = %response.object_key,
|
||||
expires_at = %response.expires_at,
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS GetObject 读签名完成"
|
||||
),
|
||||
Err(error) => warn!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "sign_get_object_url",
|
||||
bucket = %self.config.bucket(),
|
||||
endpoint = %self.config.endpoint(),
|
||||
object_key = %requested_object_key,
|
||||
error_kind = oss_error_kind_label(error),
|
||||
message = %error,
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS GetObject 读签名失败"
|
||||
),
|
||||
}
|
||||
|
||||
let object_key = normalize_object_key(&request.object_key)?;
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
||||
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
|
||||
)?))
|
||||
.ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
|
||||
let expires_at_text = expires_at
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
|
||||
|
||||
let signed_at = OffsetDateTime::now_utc();
|
||||
let signed_at_text = build_v4_signature_date(signed_at)?;
|
||||
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
|
||||
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
|
||||
let mut query = BTreeMap::from([
|
||||
("x-oss-additional-headers".to_string(), "host".to_string()),
|
||||
(
|
||||
"x-oss-signature-version".to_string(),
|
||||
OSS_V4_ALGORITHM.to_string(),
|
||||
),
|
||||
("x-oss-credential".to_string(), credential),
|
||||
("x-oss-date".to_string(), signed_at_text),
|
||||
("x-oss-expires".to_string(), expire_seconds.to_string()),
|
||||
]);
|
||||
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
|
||||
let object_url_path = format!("/{}", encode_url_path(&object_key));
|
||||
let additional_headers = "host";
|
||||
let canonical_headers =
|
||||
format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
|
||||
let canonical_query = build_canonical_query_string(&query);
|
||||
let canonical_request = build_v4_canonical_request(
|
||||
Method::GET.as_str(),
|
||||
&canonical_uri,
|
||||
&canonical_query,
|
||||
&canonical_headers,
|
||||
additional_headers,
|
||||
OSS_UNSIGNED_PAYLOAD,
|
||||
);
|
||||
let string_to_sign = build_v4_string_to_sign(
|
||||
query["x-oss-date"].as_str(),
|
||||
&signature_scope,
|
||||
&canonical_request,
|
||||
);
|
||||
let signature = sign_v4_content(
|
||||
&self.config.access_key_secret,
|
||||
&signature_scope,
|
||||
&string_to_sign,
|
||||
)?;
|
||||
query.insert("x-oss-signature".to_string(), signature);
|
||||
let signed_url = format!(
|
||||
"{}{}?{}",
|
||||
self.config.upload_host(),
|
||||
object_url_path,
|
||||
build_canonical_query_string(&query)
|
||||
);
|
||||
|
||||
Ok(OssSignedGetObjectUrlResponse {
|
||||
provider: "aliyun-oss",
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
object_key,
|
||||
expires_at: expires_at_text,
|
||||
signed_url,
|
||||
})
|
||||
result
|
||||
}
|
||||
|
||||
// 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。
|
||||
@@ -562,59 +651,107 @@ impl OssClient {
|
||||
client: &reqwest::Client,
|
||||
request: OssHeadObjectRequest,
|
||||
) -> Result<OssHeadObjectResponse, OssError> {
|
||||
let object_key = normalize_object_key(&request.object_key)?;
|
||||
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
|
||||
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
|
||||
let response = send_signed_request(
|
||||
client,
|
||||
&self.config,
|
||||
Method::HEAD,
|
||||
Some(&object_key),
|
||||
target_url,
|
||||
)
|
||||
.await?;
|
||||
let started_at = Instant::now();
|
||||
let requested_object_key = request
|
||||
.object_key
|
||||
.trim()
|
||||
.trim_start_matches('/')
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut response_status = None;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(OssError::ObjectNotFound(format!(
|
||||
"OSS 对象不存在:{}",
|
||||
request.object_key
|
||||
)));
|
||||
let result = async {
|
||||
let object_key = normalize_object_key(&request.object_key)?;
|
||||
let target_url =
|
||||
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|
||||
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
|
||||
)?;
|
||||
let response = send_signed_request(
|
||||
client,
|
||||
&self.config,
|
||||
Method::HEAD,
|
||||
Some(&object_key),
|
||||
target_url,
|
||||
)
|
||||
.await?;
|
||||
response_status = Some(response.status().as_u16());
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(OssError::ObjectNotFound(format!(
|
||||
"OSS 对象不存在:{}",
|
||||
request.object_key
|
||||
)));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(OssError::Request(format!(
|
||||
"OSS HEAD Object 失败,状态码:{}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let headers = response.headers();
|
||||
let content_length = headers
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let content_type = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
let etag = headers
|
||||
.get(reqwest::header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.trim_matches('"').to_string());
|
||||
let last_modified = headers
|
||||
.get(reqwest::header::LAST_MODIFIED)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
Ok(OssHeadObjectResponse {
|
||||
bucket: self.config.bucket.clone(),
|
||||
object_key,
|
||||
content_length,
|
||||
content_type,
|
||||
etag,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(response) => info!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "head_object",
|
||||
bucket = %response.bucket,
|
||||
endpoint = %self.config.endpoint(),
|
||||
object_key = %response.object_key,
|
||||
status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()),
|
||||
status_class = http_status_class_from_option(response_status),
|
||||
content_length = response.content_length,
|
||||
content_type = %response.content_type.as_deref().unwrap_or(""),
|
||||
etag_present = response.etag.is_some(),
|
||||
last_modified_present = response.last_modified.is_some(),
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS HEAD Object 完成"
|
||||
),
|
||||
Err(error) => warn!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "head_object",
|
||||
bucket = %self.config.bucket(),
|
||||
endpoint = %self.config.endpoint(),
|
||||
object_key = %requested_object_key,
|
||||
status = response_status.unwrap_or_default(),
|
||||
status_class = http_status_class_from_option(response_status),
|
||||
error_kind = oss_error_kind_label(error),
|
||||
message = %error,
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS HEAD Object 失败"
|
||||
),
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(OssError::Request(format!(
|
||||
"OSS HEAD Object 失败,状态码:{}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let headers = response.headers();
|
||||
let content_length = headers
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let content_type = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
let etag = headers
|
||||
.get(reqwest::header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.trim_matches('"').to_string());
|
||||
let last_modified = headers
|
||||
.get(reqwest::header::LAST_MODIFIED)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
Ok(OssHeadObjectResponse {
|
||||
bucket: self.config.bucket.clone(),
|
||||
object_key,
|
||||
content_length,
|
||||
content_type,
|
||||
etag,
|
||||
last_modified,
|
||||
})
|
||||
result
|
||||
}
|
||||
|
||||
// AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。
|
||||
@@ -623,73 +760,128 @@ impl OssClient {
|
||||
client: &reqwest::Client,
|
||||
request: OssPutObjectRequest,
|
||||
) -> Result<OssPutObjectResponse, OssError> {
|
||||
if request.body.is_empty() {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"服务端上传对象内容不能为空".to_string(),
|
||||
));
|
||||
let started_at = Instant::now();
|
||||
let requested_prefix = request.prefix.as_str();
|
||||
let requested_content_type = request
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let requested_content_length = request.body.len();
|
||||
let requested_metadata_count = request.metadata.len();
|
||||
let mut response_status = None;
|
||||
|
||||
let result = async {
|
||||
if request.body.is_empty() {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"服务端上传对象内容不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_segments = request
|
||||
.path_segments
|
||||
.iter()
|
||||
.map(|segment| sanitize_path_segment(segment))
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let file_name = sanitize_file_name(&request.file_name)?;
|
||||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||||
let content_type = normalize_optional_value(request.content_type);
|
||||
let metadata = normalize_metadata(request.metadata)?;
|
||||
let target_url =
|
||||
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|
||||
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
|
||||
)?;
|
||||
let content_length = u64::try_from(request.body.len())
|
||||
.map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
|
||||
let builder = signed_request_builder(
|
||||
client,
|
||||
&self.config,
|
||||
Method::PUT,
|
||||
Some(&object_key),
|
||||
target_url,
|
||||
content_type.as_deref(),
|
||||
&metadata,
|
||||
)?
|
||||
.header(reqwest::header::CONTENT_LENGTH, content_length)
|
||||
.body(request.body);
|
||||
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
|
||||
response_status = Some(response.status().as_u16());
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(OssError::Request(format!(
|
||||
"OSS PutObject 失败,状态码:{}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let headers = response.headers();
|
||||
let etag = headers
|
||||
.get(reqwest::header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.trim_matches('"').to_string());
|
||||
let last_modified = headers
|
||||
.get(reqwest::header::LAST_MODIFIED)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
Ok(OssPutObjectResponse {
|
||||
provider: OSS_PROVIDER,
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
legacy_public_path: format!("/{object_key}"),
|
||||
object_key,
|
||||
content_type,
|
||||
content_length,
|
||||
access: request.access,
|
||||
etag,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(response) => info!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "put_object",
|
||||
bucket = %response.bucket,
|
||||
endpoint = %response.endpoint,
|
||||
object_key = %response.object_key,
|
||||
access = oss_access_label(response.access),
|
||||
status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()),
|
||||
status_class = http_status_class_from_option(response_status),
|
||||
content_length = response.content_length,
|
||||
content_type = %response.content_type.as_deref().unwrap_or(""),
|
||||
etag_present = response.etag.is_some(),
|
||||
last_modified_present = response.last_modified.is_some(),
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS PutObject 上传完成"
|
||||
),
|
||||
Err(error) => warn!(
|
||||
provider = OSS_PROVIDER,
|
||||
operation = "put_object",
|
||||
bucket = %self.config.bucket(),
|
||||
endpoint = %self.config.endpoint(),
|
||||
key_prefix = requested_prefix,
|
||||
content_length = requested_content_length,
|
||||
content_type = %requested_content_type,
|
||||
metadata_count = requested_metadata_count,
|
||||
status = response_status.unwrap_or_default(),
|
||||
status_class = http_status_class_from_option(response_status),
|
||||
error_kind = oss_error_kind_label(error),
|
||||
message = %error,
|
||||
elapsed_ms = elapsed_ms(started_at),
|
||||
"OSS PutObject 上传失败"
|
||||
),
|
||||
}
|
||||
|
||||
let sanitized_segments = request
|
||||
.path_segments
|
||||
.iter()
|
||||
.map(|segment| sanitize_path_segment(segment))
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let file_name = sanitize_file_name(&request.file_name)?;
|
||||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||||
let content_type = normalize_optional_value(request.content_type);
|
||||
let metadata = normalize_metadata(request.metadata)?;
|
||||
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
|
||||
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
|
||||
let content_length = u64::try_from(request.body.len())
|
||||
.map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
|
||||
let builder = signed_request_builder(
|
||||
client,
|
||||
&self.config,
|
||||
Method::PUT,
|
||||
Some(&object_key),
|
||||
target_url,
|
||||
content_type.as_deref(),
|
||||
&metadata,
|
||||
)?
|
||||
.header(reqwest::header::CONTENT_LENGTH, content_length)
|
||||
.body(request.body);
|
||||
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(OssError::Request(format!(
|
||||
"OSS PutObject 失败,状态码:{}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let headers = response.headers();
|
||||
let etag = headers
|
||||
.get(reqwest::header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.trim_matches('"').to_string());
|
||||
let last_modified = headers
|
||||
.get(reqwest::header::LAST_MODIFIED)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
Ok(OssPutObjectResponse {
|
||||
provider: "aliyun-oss",
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
legacy_public_path: format!("/{object_key}"),
|
||||
object_key,
|
||||
content_type,
|
||||
content_length,
|
||||
access: request.access,
|
||||
etag,
|
||||
last_modified,
|
||||
})
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +913,43 @@ impl OssError {
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started_at: Instant) -> u64 {
|
||||
started_at.elapsed().as_millis().min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
fn oss_access_label(access: OssObjectAccess) -> &'static str {
|
||||
match access {
|
||||
OssObjectAccess::Public => "public",
|
||||
OssObjectAccess::Private => "private",
|
||||
}
|
||||
}
|
||||
|
||||
fn oss_error_kind_label(error: &OssError) -> &'static str {
|
||||
match error.kind() {
|
||||
OssErrorKind::InvalidConfig => "invalid_config",
|
||||
OssErrorKind::InvalidRequest => "invalid_request",
|
||||
OssErrorKind::ObjectNotFound => "object_not_found",
|
||||
OssErrorKind::Request => "request",
|
||||
OssErrorKind::SerializePolicy => "serialize_policy",
|
||||
OssErrorKind::Sign => "sign",
|
||||
}
|
||||
}
|
||||
|
||||
fn http_status_class_from_option(status: Option<u16>) -> &'static str {
|
||||
status.map(http_status_class).unwrap_or("unknown")
|
||||
}
|
||||
|
||||
fn http_status_class(status: u16) -> &'static str {
|
||||
match status {
|
||||
100..=199 => "1xx",
|
||||
200..=299 => "2xx",
|
||||
300..=399 => "3xx",
|
||||
400..=499 => "4xx",
|
||||
500..=599 => "5xx",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_policy_json(
|
||||
bucket: &str,
|
||||
object_key: &str,
|
||||
@@ -1299,6 +1528,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_log_labels_are_stable() {
|
||||
assert_eq!(
|
||||
oss_error_kind_label(&OssError::InvalidRequest("bad input".to_string())),
|
||||
"invalid_request"
|
||||
);
|
||||
assert_eq!(oss_access_label(OssObjectAccess::Private), "private");
|
||||
assert_eq!(http_status_class(204), "2xx");
|
||||
assert_eq!(http_status_class(404), "4xx");
|
||||
assert_eq!(http_status_class_from_option(None), "unknown");
|
||||
}
|
||||
|
||||
fn build_client() -> OssClient {
|
||||
OssClient::new(
|
||||
OssConfig::new(
|
||||
|
||||
@@ -19,10 +19,13 @@ pub struct AuthUserPayload {
|
||||
pub public_user_code: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub phone_number: Option<String>,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: String,
|
||||
pub binding_status: String,
|
||||
pub wechat_bound: bool,
|
||||
pub wechat_display_name: Option<String>,
|
||||
pub wechat_account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -44,7 +44,6 @@ pub enum JumpHopTileType {
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum JumpHopActionType {
|
||||
CompileDraft,
|
||||
RegenerateCharacter,
|
||||
RegenerateTiles,
|
||||
UpdateWorkMeta,
|
||||
UpdateDifficulty,
|
||||
@@ -71,12 +70,20 @@ pub enum JumpHopJumpResult {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopWorkspaceCreateRequest {
|
||||
pub template_id: String,
|
||||
pub theme_text: String,
|
||||
#[serde(default)]
|
||||
pub work_title: String,
|
||||
#[serde(default)]
|
||||
pub work_description: String,
|
||||
#[serde(default)]
|
||||
pub theme_tags: Vec<String>,
|
||||
#[serde(default = "default_jump_hop_difficulty")]
|
||||
pub difficulty: JumpHopDifficulty,
|
||||
#[serde(default = "default_jump_hop_style_preset")]
|
||||
pub style_preset: JumpHopStylePreset,
|
||||
#[serde(default)]
|
||||
pub character_prompt: String,
|
||||
#[serde(default)]
|
||||
pub tile_prompt: String,
|
||||
#[serde(default)]
|
||||
pub end_mood_prompt: Option<String>,
|
||||
@@ -89,6 +96,8 @@ pub struct JumpHopActionRequest {
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_description: Option<String>,
|
||||
@@ -112,6 +121,8 @@ pub struct JumpHopActionRequest {
|
||||
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
|
||||
#[serde(default)]
|
||||
pub cover_composite: Option<String>,
|
||||
#[serde(default)]
|
||||
pub back_button_asset: Option<JumpHopCharacterAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -127,14 +138,30 @@ pub struct JumpHopCharacterAsset {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopDefaultCharacter {
|
||||
pub character_id: String,
|
||||
pub display_name: String,
|
||||
pub model_kind: String,
|
||||
pub body_color: String,
|
||||
pub accent_color: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopTileAsset {
|
||||
pub tile_type: JumpHopTileType,
|
||||
#[serde(default)]
|
||||
pub tile_id: Option<String>,
|
||||
pub image_src: String,
|
||||
pub image_object_key: String,
|
||||
pub asset_object_id: String,
|
||||
pub source_atlas_cell: String,
|
||||
#[serde(default)]
|
||||
pub atlas_row: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub atlas_col: Option<u32>,
|
||||
pub visual_width: u32,
|
||||
pub visual_height: u32,
|
||||
pub top_surface_radius: f32,
|
||||
@@ -193,11 +220,14 @@ pub struct JumpHopDraftResponse {
|
||||
pub template_name: String,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
pub theme_text: String,
|
||||
pub work_title: String,
|
||||
pub work_description: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub difficulty: JumpHopDifficulty,
|
||||
pub style_preset: JumpHopStylePreset,
|
||||
#[serde(default)]
|
||||
pub default_character: Option<JumpHopDefaultCharacter>,
|
||||
pub character_prompt: String,
|
||||
pub tile_prompt: String,
|
||||
#[serde(default)]
|
||||
@@ -212,6 +242,8 @@ pub struct JumpHopDraftResponse {
|
||||
pub path: Option<JumpHopPath>,
|
||||
#[serde(default)]
|
||||
pub cover_composite: Option<String>,
|
||||
#[serde(default)]
|
||||
pub back_button_asset: Option<JumpHopCharacterAsset>,
|
||||
pub generation_status: JumpHopGenerationStatus,
|
||||
}
|
||||
|
||||
@@ -251,6 +283,7 @@ pub struct JumpHopWorkSummaryResponse {
|
||||
pub owner_user_id: String,
|
||||
#[serde(default)]
|
||||
pub source_session_id: Option<String>,
|
||||
pub theme_text: String,
|
||||
pub work_title: String,
|
||||
pub work_description: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
@@ -274,9 +307,13 @@ pub struct JumpHopWorkProfileResponse {
|
||||
pub summary: JumpHopWorkSummaryResponse,
|
||||
pub draft: JumpHopDraftResponse,
|
||||
pub path: JumpHopPath,
|
||||
#[serde(default)]
|
||||
pub default_character: Option<JumpHopDefaultCharacter>,
|
||||
pub character_asset: JumpHopCharacterAsset,
|
||||
pub tile_atlas_asset: JumpHopCharacterAsset,
|
||||
pub tile_assets: Vec<JumpHopTileAsset>,
|
||||
#[serde(default)]
|
||||
pub back_button_asset: Option<JumpHopCharacterAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -305,6 +342,7 @@ pub struct JumpHopGalleryCardResponse {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub theme_text: String,
|
||||
pub work_title: String,
|
||||
pub work_description: String,
|
||||
#[serde(default)]
|
||||
@@ -343,6 +381,8 @@ pub struct JumpHopRuntimeRunSnapshotResponse {
|
||||
pub owner_user_id: String,
|
||||
pub status: JumpHopRunStatus,
|
||||
pub current_platform_index: u32,
|
||||
pub successful_jump_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub score: u32,
|
||||
pub combo: u32,
|
||||
pub path: JumpHopPath,
|
||||
@@ -363,15 +403,29 @@ pub struct JumpHopRunResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopStartRunRequest {
|
||||
pub profile_id: String,
|
||||
#[serde(default)]
|
||||
pub runtime_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopJumpRequest {
|
||||
pub charge_ms: u32,
|
||||
pub drag_distance: f32,
|
||||
#[serde(default)]
|
||||
pub drag_vector_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub drag_vector_y: Option<f32>,
|
||||
pub client_event_id: String,
|
||||
}
|
||||
|
||||
fn default_jump_hop_difficulty() -> JumpHopDifficulty {
|
||||
JumpHopDifficulty::Standard
|
||||
}
|
||||
|
||||
fn default_jump_hop_style_preset() -> JumpHopStylePreset {
|
||||
JumpHopStylePreset::MinimalBlocks
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopRestartRunRequest {
|
||||
@@ -384,6 +438,25 @@ pub struct JumpHopJumpResponse {
|
||||
pub run: JumpHopRuntimeRunSnapshotResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopLeaderboardEntry {
|
||||
pub rank: u32,
|
||||
pub player_id: String,
|
||||
pub successful_jump_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopLeaderboardResponse {
|
||||
pub profile_id: String,
|
||||
pub items: Vec<JumpHopLeaderboardEntry>,
|
||||
#[serde(default)]
|
||||
pub viewer_best: Option<JumpHopLeaderboardEntry>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -393,6 +466,7 @@ mod tests {
|
||||
fn jump_hop_workspace_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest {
|
||||
template_id: "jump-hop".to_string(),
|
||||
theme_text: "跳一跳".to_string(),
|
||||
work_title: "跳一跳".to_string(),
|
||||
work_description: "俯视角跳跃闯关".to_string(),
|
||||
theme_tags: vec!["休闲".to_string()],
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::HashMap;
|
||||
|
||||
pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput;
|
||||
pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput;
|
||||
pub type BarkBattleWorkDeleteRecordInput = BarkBattleWorkDeleteInput;
|
||||
pub type BarkBattleWorkPublishRecordInput = BarkBattleWorkPublishInput;
|
||||
pub type BarkBattleRunStartRecordInput = BarkBattleRunStartInput;
|
||||
pub type BarkBattleRunFinishRecordInput = BarkBattleRunFinishInput;
|
||||
@@ -88,6 +89,34 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_bark_battle_work(
|
||||
&self,
|
||||
input: BarkBattleWorkDeleteRecordInput,
|
||||
) -> Result<Vec<serde_json::Value>, SpacetimeClientError> {
|
||||
let owner_user_id = input.owner_user_id.clone();
|
||||
self.call_after_connect("delete_bark_battle_work", move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.delete_bark_battle_work_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(|result| {
|
||||
if result.ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SpacetimeClientError::procedure_failed(
|
||||
result.error_message,
|
||||
))
|
||||
}
|
||||
});
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.list_bark_battle_works(owner_user_id).await
|
||||
}
|
||||
|
||||
pub async fn get_bark_battle_runtime_config(
|
||||
&self,
|
||||
work_id: String,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use super::*;
|
||||
use crate::mapper::{
|
||||
map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
|
||||
map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result,
|
||||
map_jump_hop_works_procedure_result,
|
||||
map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
|
||||
map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
|
||||
};
|
||||
use shared_contracts::jump_hop::{
|
||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||
JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
||||
JumpHopTileType, JumpHopWorkProfileResponse,
|
||||
JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||||
JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
||||
JumpHopStylePreset, JumpHopWorkProfileResponse,
|
||||
};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
|
||||
@@ -222,6 +222,30 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_jump_hop_work(
|
||||
&self,
|
||||
profile_id: String,
|
||||
owner_user_id: String,
|
||||
) -> Result<Vec<JumpHopWorkProfileResponse>, SpacetimeClientError> {
|
||||
let procedure_input = JumpHopWorkDeleteInput {
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
};
|
||||
|
||||
self.call_after_connect("delete_jump_hop_work", move |connection, sender| {
|
||||
connection.procedures().delete_jump_hop_work_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_jump_hop_works_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_jump_hop_runtime_work(
|
||||
&self,
|
||||
profile_id: String,
|
||||
@@ -229,7 +253,7 @@ impl SpacetimeClient {
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
validate_jump_hop_runtime_ready(&work, "published")?;
|
||||
Ok(work)
|
||||
}
|
||||
|
||||
@@ -238,17 +262,24 @@ impl SpacetimeClient {
|
||||
payload: JumpHopStartRunRequest,
|
||||
owner_user_id: String,
|
||||
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
|
||||
let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref());
|
||||
let profile_id = payload.profile_id;
|
||||
let work_owner_user_id = if runtime_mode == "draft" {
|
||||
owner_user_id.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||
.get_jump_hop_work_profile(profile_id.clone(), work_owner_user_id)
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
validate_jump_hop_runtime_ready(&work, runtime_mode)?;
|
||||
let run_id = build_prefixed_uuid_id("jump-hop-run-");
|
||||
let procedure_input = JumpHopRunStartInput {
|
||||
client_event_id: format!("{run_id}:start"),
|
||||
run_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
runtime_mode: runtime_mode.to_string(),
|
||||
started_at_ms: current_unix_micros().div_euclid(1000),
|
||||
};
|
||||
self.start_jump_hop_run_with_input(procedure_input).await
|
||||
@@ -303,7 +334,9 @@ impl SpacetimeClient {
|
||||
let procedure_input = JumpHopRunJumpInput {
|
||||
run_id,
|
||||
owner_user_id,
|
||||
charge_ms: payload.charge_ms,
|
||||
drag_distance: payload.drag_distance,
|
||||
drag_vector_x: payload.drag_vector_x,
|
||||
drag_vector_y: payload.drag_vector_y,
|
||||
client_event_id: payload.client_event_id,
|
||||
jumped_at_ms: current_unix_micros().div_euclid(1000),
|
||||
};
|
||||
@@ -396,13 +429,39 @@ impl SpacetimeClient {
|
||||
self.get_jump_hop_work_profile(card.profile_id, String::new())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_jump_hop_leaderboard(
|
||||
&self,
|
||||
profile_id: String,
|
||||
viewer_player_id: String,
|
||||
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
|
||||
let procedure_input = JumpHopLeaderboardGetInput {
|
||||
profile_id,
|
||||
viewer_player_id,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
self.call_after_connect("get_jump_hop_leaderboard", move |connection, sender| {
|
||||
connection.procedures().get_jump_hop_leaderboard_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_jump_hop_leaderboard_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_jump_hop_runtime_ready(
|
||||
work: &JumpHopWorkProfileResponse,
|
||||
runtime_mode: &str,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
||||
if status != "published" {
|
||||
if runtime_mode == "published" && status != "published" {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 只能启动已发布作品",
|
||||
));
|
||||
@@ -412,11 +471,11 @@ fn validate_jump_hop_runtime_ready(
|
||||
"jump-hop runtime 需要 ready 状态作品",
|
||||
));
|
||||
}
|
||||
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
|
||||
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||
if work.tile_assets.is_empty() {
|
||||
validate_jump_hop_default_character_ready(work)?;
|
||||
validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||
if work.tile_assets.len() < 25 {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少地块资产",
|
||||
"jump-hop runtime 需要 25 个地块资产",
|
||||
));
|
||||
}
|
||||
for (index, asset) in work.tile_assets.iter().enumerate() {
|
||||
@@ -437,7 +496,34 @@ fn validate_jump_hop_runtime_ready(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_jump_hop_character_asset_ready(
|
||||
fn normalize_jump_hop_runtime_mode(value: Option<&str>) -> &'static str {
|
||||
if value
|
||||
.map(|value| value.trim().eq_ignore_ascii_case("draft"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"draft"
|
||||
} else {
|
||||
"published"
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_jump_hop_default_character_ready(
|
||||
work: &JumpHopWorkProfileResponse,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let Some(default_character) = work.default_character.as_ref() else {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少内置默认角色配置",
|
||||
));
|
||||
};
|
||||
if default_character.model_kind.trim() != "builtin-three" {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 默认角色必须使用 builtin-three",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_jump_hop_tile_atlas_asset_ready(
|
||||
asset: &JumpHopCharacterAsset,
|
||||
field: &str,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
@@ -475,7 +561,6 @@ enum JumpHopActionProcedure {
|
||||
#[derive(Clone, Copy)]
|
||||
enum JumpHopDraftMergeScope {
|
||||
CompileDraft,
|
||||
RegenerateCharacter,
|
||||
RegenerateTiles,
|
||||
UpdateWorkMeta,
|
||||
UpdateDifficulty,
|
||||
@@ -484,7 +569,6 @@ enum JumpHopDraftMergeScope {
|
||||
#[derive(Clone, Copy)]
|
||||
enum JumpHopAssetRefresh {
|
||||
Preserve,
|
||||
Character,
|
||||
Tiles,
|
||||
}
|
||||
|
||||
@@ -496,12 +580,18 @@ fn build_jump_hop_action_plan(
|
||||
) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> {
|
||||
let scope = match payload.action_type {
|
||||
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
|
||||
JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter,
|
||||
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
|
||||
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
|
||||
JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty,
|
||||
};
|
||||
let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?;
|
||||
let mut base_draft = current.draft.clone();
|
||||
if matches!(payload.action_type, JumpHopActionType::RegenerateTiles) {
|
||||
if let Some(draft) = base_draft.as_mut() {
|
||||
draft.tile_atlas_asset = None;
|
||||
draft.tile_assets.clear();
|
||||
}
|
||||
}
|
||||
let mut draft = merge_action_into_draft(base_draft, payload, scope)?;
|
||||
let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?;
|
||||
draft.profile_id = Some(profile_id.clone());
|
||||
|
||||
@@ -514,16 +604,6 @@ fn build_jump_hop_action_plan(
|
||||
JumpHopAssetRefresh::Preserve,
|
||||
now_micros,
|
||||
)?),
|
||||
JumpHopActionType::RegenerateCharacter => {
|
||||
JumpHopActionProcedure::Compile(build_compile_input(
|
||||
current,
|
||||
owner_user_id,
|
||||
&profile_id,
|
||||
&mut draft,
|
||||
JumpHopAssetRefresh::Character,
|
||||
now_micros,
|
||||
)?)
|
||||
}
|
||||
JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input(
|
||||
current,
|
||||
owner_user_id,
|
||||
@@ -563,6 +643,13 @@ fn merge_action_into_draft(
|
||||
{
|
||||
draft.work_title = value.trim().to_string();
|
||||
}
|
||||
if let Some(value) = payload
|
||||
.theme_text
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.theme_text = value.trim().chars().take(60).collect();
|
||||
}
|
||||
if let Some(value) = payload.work_description.as_ref() {
|
||||
draft.work_description = value.trim().to_string();
|
||||
}
|
||||
@@ -590,10 +677,7 @@ fn merge_action_into_draft(
|
||||
.filter(|value| !value.is_empty());
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) {
|
||||
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
|
||||
if let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
@@ -622,10 +706,7 @@ fn merge_action_into_draft(
|
||||
{
|
||||
draft.profile_id = Some(profile_id.to_string());
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) {
|
||||
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
|
||||
if let Some(asset) = payload.character_asset.clone() {
|
||||
draft.character_asset = Some(asset);
|
||||
}
|
||||
@@ -649,6 +730,14 @@ fn merge_action_into_draft(
|
||||
{
|
||||
draft.cover_composite = Some(value.to_string());
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) {
|
||||
if let Some(asset) = payload.back_button_asset.clone() {
|
||||
draft.back_button_asset = Some(asset);
|
||||
}
|
||||
}
|
||||
if draft.work_title.trim().is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop work_title 不能为空",
|
||||
@@ -665,28 +754,19 @@ fn build_compile_input(
|
||||
refresh: JumpHopAssetRefresh,
|
||||
now_micros: i64,
|
||||
) -> Result<JumpHopDraftCompileInput, SpacetimeClientError> {
|
||||
let force_character = matches!(refresh, JumpHopAssetRefresh::Character);
|
||||
let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles);
|
||||
if force_character {
|
||||
draft.character_asset = None;
|
||||
}
|
||||
if force_tiles {
|
||||
draft.tile_atlas_asset = None;
|
||||
draft.tile_assets.clear();
|
||||
}
|
||||
let character_asset = draft.character_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let character_asset = draft.character_asset.clone().unwrap_or_else(|| {
|
||||
build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str())
|
||||
});
|
||||
draft.character_asset = Some(character_asset.clone());
|
||||
draft.default_character = Some(default_jump_hop_default_character());
|
||||
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_assets = if draft.tile_assets.is_empty() {
|
||||
let tile_assets = if draft.tile_assets.len() < 25 {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||
"jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||
));
|
||||
} else {
|
||||
draft.tile_assets.clone()
|
||||
@@ -705,7 +785,7 @@ fn build_compile_input(
|
||||
work_title: draft.work_title.clone(),
|
||||
work_description: draft.work_description.clone(),
|
||||
theme_tags_json: Some(json_string(&draft.theme_tags)?),
|
||||
theme_text: Some(draft.work_title.clone()),
|
||||
theme_text: Some(draft.theme_text.clone()),
|
||||
difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()),
|
||||
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
|
||||
character_prompt: Some(draft.character_prompt.clone()),
|
||||
@@ -715,6 +795,11 @@ fn build_compile_input(
|
||||
tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?),
|
||||
tile_assets_json: Some(json_string(&tile_assets)?),
|
||||
cover_composite,
|
||||
back_button_asset_json: draft
|
||||
.back_button_asset
|
||||
.as_ref()
|
||||
.map(json_string)
|
||||
.transpose()?,
|
||||
generation_status: Some("ready".to_string()),
|
||||
compiled_at_micros: now_micros,
|
||||
})
|
||||
@@ -785,26 +870,29 @@ fn default_draft() -> JumpHopDraftResponse {
|
||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||
profile_id: None,
|
||||
theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||
work_title: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||
work_description: "俯视角跳跃闯关".to_string(),
|
||||
theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()],
|
||||
difficulty: JumpHopDifficulty::Standard,
|
||||
style_preset: JumpHopStylePreset::MinimalBlocks,
|
||||
character_prompt: "俯视角可爱主角,透明背景".to_string(),
|
||||
tile_prompt: "等距立体地块图集".to_string(),
|
||||
default_character: Some(default_jump_hop_default_character()),
|
||||
character_prompt: "内置默认 3D 角色".to_string(),
|
||||
tile_prompt: "跳一跳主题的正面30度视角主题物体图集,物体本身作为跳跃落点".to_string(),
|
||||
end_mood_prompt: None,
|
||||
character_asset: None,
|
||||
tile_atlas_asset: None,
|
||||
tile_assets: Vec::new(),
|
||||
path: None,
|
||||
cover_composite: None,
|
||||
back_button_asset: None,
|
||||
generation_status: JumpHopGenerationStatus::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeClientError> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"themeText": draft.work_title,
|
||||
"themeText": draft.theme_text,
|
||||
"difficulty": difficulty_to_str(&draft.difficulty),
|
||||
"stylePreset": style_to_str(&draft.style_preset),
|
||||
"characterPrompt": draft.character_prompt,
|
||||
@@ -814,94 +902,6 @@ fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeCl
|
||||
.map_err(SpacetimeClientError::validation_failed)
|
||||
}
|
||||
|
||||
fn ensure_character_asset(
|
||||
existing: Option<JumpHopCharacterAsset>,
|
||||
profile_id: &str,
|
||||
prompt: &str,
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
JumpHopCharacterAsset {
|
||||
asset_id: format!("{profile_id}-character{suffix}"),
|
||||
image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
|
||||
image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
|
||||
asset_object_id: format!("{profile_id}-character{suffix}-object"),
|
||||
generation_provider: "deterministic-placeholder".to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
width: 768,
|
||||
height: 768,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_tile_atlas_asset(
|
||||
existing: Option<JumpHopCharacterAsset>,
|
||||
profile_id: &str,
|
||||
prompt: &str,
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
JumpHopCharacterAsset {
|
||||
asset_id: format!("{profile_id}-tile-atlas{suffix}"),
|
||||
image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
|
||||
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
|
||||
asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"),
|
||||
generation_provider: "deterministic-placeholder".to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_tile_assets(
|
||||
existing: Vec<JumpHopTileAsset>,
|
||||
profile_id: &str,
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> Vec<JumpHopTileAsset> {
|
||||
if !force_new && !existing.is_empty() {
|
||||
return existing;
|
||||
}
|
||||
let suffix = asset_revision_suffix(force_new.then_some(now_micros));
|
||||
[
|
||||
JumpHopTileType::Start,
|
||||
JumpHopTileType::Normal,
|
||||
JumpHopTileType::Target,
|
||||
JumpHopTileType::Finish,
|
||||
JumpHopTileType::Bonus,
|
||||
JumpHopTileType::Accent,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, tile_type)| JumpHopTileAsset {
|
||||
tile_type,
|
||||
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"),
|
||||
image_object_key: format!(
|
||||
"generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"
|
||||
),
|
||||
asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"),
|
||||
source_atlas_cell: format!("cell-{index}{suffix}"),
|
||||
visual_width: 256,
|
||||
visual_height: 192,
|
||||
top_surface_radius: 42.0,
|
||||
landing_radius: 34.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_cover_composite(
|
||||
draft: &JumpHopDraftResponse,
|
||||
profile_id: &str,
|
||||
@@ -926,6 +926,22 @@ fn resolve_cover_composite(
|
||||
))
|
||||
}
|
||||
|
||||
fn build_jump_hop_default_character_asset(
|
||||
profile_id: &str,
|
||||
theme_text: &str,
|
||||
) -> JumpHopCharacterAsset {
|
||||
JumpHopCharacterAsset {
|
||||
asset_id: format!("{profile_id}-builtin-character"),
|
||||
image_src: "builtin://jump-hop/default-character".to_string(),
|
||||
image_object_key: String::new(),
|
||||
asset_object_id: format!("{profile_id}-builtin-character"),
|
||||
generation_provider: "builtin-three".to_string(),
|
||||
prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn asset_revision_suffix(revision: Option<i64>) -> String {
|
||||
revision
|
||||
.filter(|value| *value > 0)
|
||||
@@ -957,6 +973,16 @@ fn style_to_str(value: &JumpHopStylePreset) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_jump_hop_default_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
|
||||
shared_contracts::jump_hop::JumpHopDefaultCharacter {
|
||||
character_id: "jump-hop-default-runner".to_string(),
|
||||
display_name: "默认角色".to_string(),
|
||||
model_kind: "builtin-three".to_string(),
|
||||
body_color: "#f59e0b".to_string(),
|
||||
accent_color: "#2563eb".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -968,8 +994,9 @@ mod tests {
|
||||
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
||||
|
||||
#[test]
|
||||
fn jump_hop_action_compile_draft_builds_compile_input_with_assets() {
|
||||
let session = session_with_draft(draft_without_assets());
|
||||
fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character()
|
||||
{
|
||||
let session = session_with_draft(draft_without_character_asset());
|
||||
let payload = action(JumpHopActionType::CompileDraft);
|
||||
|
||||
let (plan, draft) =
|
||||
@@ -987,7 +1014,7 @@ mod tests {
|
||||
.character_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("-character")
|
||||
.contains("builtin-three")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
@@ -1001,59 +1028,19 @@ mod tests {
|
||||
.tile_assets_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("tile-0-object")
|
||||
.contains("old-tile-25-object")
|
||||
);
|
||||
assert_eq!(draft.tile_assets.len(), 25);
|
||||
assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() {
|
||||
let session = session_with_draft(draft_with_assets());
|
||||
let mut payload = action(JumpHopActionType::RegenerateCharacter);
|
||||
payload.character_prompt = Some("新的主角提示词".to_string());
|
||||
|
||||
let (plan, _draft) =
|
||||
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
.expect("regenerate-character should build plan");
|
||||
|
||||
let JumpHopActionProcedure::Compile(input) = plan else {
|
||||
panic!("regenerate-character should call compile_jump_hop_draft");
|
||||
};
|
||||
assert!(
|
||||
!input
|
||||
.character_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("old-character")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.character_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains(&NOW_MICROS.to_string())
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.tile_atlas_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("old-tile-atlas")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.tile_assets_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("old-normal-tile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() {
|
||||
let session = session_with_draft(draft_with_assets());
|
||||
let mut payload = action(JumpHopActionType::RegenerateTiles);
|
||||
payload.tile_prompt = Some("新的地块提示词".to_string());
|
||||
payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS));
|
||||
payload.tile_assets = Some(tile_assets("new", 25));
|
||||
|
||||
let (plan, _draft) =
|
||||
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
@@ -1067,7 +1054,7 @@ mod tests {
|
||||
.character_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("old-character")
|
||||
.contains("builtin-three")
|
||||
);
|
||||
assert!(
|
||||
!input
|
||||
@@ -1081,24 +1068,43 @@ mod tests {
|
||||
.tile_assets_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("old-normal-tile")
|
||||
.contains("old-tile-01-object")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.tile_atlas_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains(&NOW_MICROS.to_string())
|
||||
.contains("new-tile-atlas")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.tile_assets_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains(&NOW_MICROS.to_string())
|
||||
.contains("new-tile-25-object")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_action_compile_draft_persists_theme_text_separately_from_title() {
|
||||
let session = session_with_draft(draft_without_character_asset());
|
||||
let mut payload = action(JumpHopActionType::CompileDraft);
|
||||
payload.theme_text = Some(" 森林蘑菇跳台 ".to_string());
|
||||
payload.work_title = Some("自动标题".to_string());
|
||||
|
||||
let (plan, draft) =
|
||||
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
.expect("compile-draft should build plan");
|
||||
|
||||
let JumpHopActionProcedure::Compile(input) = plan else {
|
||||
panic!("compile-draft should call compile_jump_hop_draft");
|
||||
};
|
||||
assert_eq!(draft.theme_text, "森林蘑菇跳台");
|
||||
assert_eq!(input.theme_text.as_deref(), Some("森林蘑菇跳台"));
|
||||
assert_eq!(input.work_title, "自动标题");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() {
|
||||
let session = session_with_draft(draft_with_assets());
|
||||
@@ -1143,22 +1149,22 @@ mod tests {
|
||||
.character_asset
|
||||
.as_ref()
|
||||
.map(|asset| asset.asset_id.as_str()),
|
||||
Some("old-character")
|
||||
Some("jump-hop-profile-test-builtin-character")
|
||||
);
|
||||
assert_eq!(
|
||||
draft
|
||||
.tile_assets
|
||||
.first()
|
||||
.map(|asset| asset.asset_object_id.as_str()),
|
||||
Some("old-normal-tile-object")
|
||||
Some("old-tile-01-object")
|
||||
);
|
||||
}
|
||||
|
||||
/// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。
|
||||
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
|
||||
JumpHopActionRequest {
|
||||
action_type,
|
||||
profile_id: None,
|
||||
theme_text: None,
|
||||
work_title: None,
|
||||
work_description: None,
|
||||
theme_tags: None,
|
||||
@@ -1185,9 +1191,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn draft_without_assets() -> JumpHopDraftResponse {
|
||||
fn draft_without_character_asset() -> JumpHopDraftResponse {
|
||||
JumpHopDraftResponse {
|
||||
profile_id: None,
|
||||
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
|
||||
tile_assets: tile_assets("old", 25),
|
||||
..base_draft()
|
||||
}
|
||||
}
|
||||
@@ -1195,37 +1203,9 @@ mod tests {
|
||||
fn draft_with_assets() -> JumpHopDraftResponse {
|
||||
JumpHopDraftResponse {
|
||||
profile_id: Some(PROFILE_ID.to_string()),
|
||||
character_asset: Some(JumpHopCharacterAsset {
|
||||
asset_id: "old-character".to_string(),
|
||||
image_src: "/generated-jump-hop-assets/old-character.png".to_string(),
|
||||
image_object_key: "generated-jump-hop-assets/old-character.png".to_string(),
|
||||
asset_object_id: "old-character-object".to_string(),
|
||||
generation_provider: "old-provider".to_string(),
|
||||
prompt: "旧角色提示词".to_string(),
|
||||
width: 768,
|
||||
height: 768,
|
||||
}),
|
||||
tile_atlas_asset: Some(JumpHopCharacterAsset {
|
||||
asset_id: "old-tile-atlas".to_string(),
|
||||
image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(),
|
||||
image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(),
|
||||
asset_object_id: "old-tile-atlas-object".to_string(),
|
||||
generation_provider: "old-provider".to_string(),
|
||||
prompt: "旧地块提示词".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}),
|
||||
tile_assets: vec![JumpHopTileAsset {
|
||||
tile_type: JumpHopTileType::Normal,
|
||||
image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(),
|
||||
image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(),
|
||||
asset_object_id: "old-normal-tile-object".to_string(),
|
||||
source_atlas_cell: "old-cell".to_string(),
|
||||
visual_width: 256,
|
||||
visual_height: 192,
|
||||
top_surface_radius: 42.0,
|
||||
landing_radius: 34.0,
|
||||
}],
|
||||
character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")),
|
||||
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
|
||||
tile_assets: tile_assets("old", 25),
|
||||
path: Some(sample_jump_hop_path()),
|
||||
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
|
||||
generation_status: JumpHopGenerationStatus::Ready,
|
||||
@@ -1233,16 +1213,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_atlas_asset(asset_id: &str, revision: i64) -> JumpHopCharacterAsset {
|
||||
let suffix = asset_revision_suffix((revision > 0).then_some(revision));
|
||||
JumpHopCharacterAsset {
|
||||
asset_id: asset_id.to_string(),
|
||||
image_src: format!("/generated-jump-hop-assets/{asset_id}{suffix}.png"),
|
||||
image_object_key: format!("generated-jump-hop-assets/{asset_id}{suffix}.png"),
|
||||
asset_object_id: format!("{asset_id}-object"),
|
||||
generation_provider: "vector-engine-image2".to_string(),
|
||||
prompt: "旧地块提示词".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_assets(prefix: &str, count: usize) -> Vec<JumpHopTileAsset> {
|
||||
(0..count)
|
||||
.map(|index| JumpHopTileAsset {
|
||||
tile_type: if index == 0 {
|
||||
JumpHopTileType::Start
|
||||
} else {
|
||||
JumpHopTileType::Normal
|
||||
},
|
||||
tile_id: Some(format!("tile-{:02}", index + 1)),
|
||||
image_src: format!("/generated-jump-hop-assets/{prefix}-tile-{}.png", index + 1),
|
||||
image_object_key: format!(
|
||||
"generated-jump-hop-assets/{prefix}-tile-{}.png",
|
||||
index + 1
|
||||
),
|
||||
asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1),
|
||||
source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1),
|
||||
atlas_row: Some(index as u32 / 5 + 1),
|
||||
atlas_col: Some(index as u32 % 5 + 1),
|
||||
visual_width: 256,
|
||||
visual_height: 192,
|
||||
top_surface_radius: 42.0,
|
||||
landing_radius: 34.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn base_draft() -> JumpHopDraftResponse {
|
||||
JumpHopDraftResponse {
|
||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||
profile_id: None,
|
||||
theme_text: "旧主题".to_string(),
|
||||
work_title: "旧标题".to_string(),
|
||||
work_description: "旧描述".to_string(),
|
||||
theme_tags: vec!["旧标签".to_string()],
|
||||
difficulty: JumpHopDifficulty::Standard,
|
||||
style_preset: JumpHopStylePreset::MinimalBlocks,
|
||||
default_character: Some(default_jump_hop_default_character()),
|
||||
character_prompt: "旧角色提示词".to_string(),
|
||||
tile_prompt: "旧地块提示词".to_string(),
|
||||
end_mood_prompt: None,
|
||||
|
||||
@@ -106,7 +106,7 @@ pub mod bark_battle;
|
||||
pub use bark_battle::{
|
||||
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
|
||||
BarkBattleRunFinishRecordInput, BarkBattleRunStartRecordInput,
|
||||
BarkBattleWorkPublishRecordInput,
|
||||
BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput,
|
||||
};
|
||||
pub mod big_fish;
|
||||
pub mod combat;
|
||||
|
||||
@@ -183,8 +183,8 @@ pub(crate) use self::inventory::{
|
||||
};
|
||||
pub(crate) use self::jump_hop::{
|
||||
map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
|
||||
map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result,
|
||||
map_jump_hop_works_procedure_result,
|
||||
map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
|
||||
map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
|
||||
};
|
||||
pub(crate) use self::match3d::{
|
||||
map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::*;
|
||||
pub use shared_contracts::jump_hop::{
|
||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
JumpHopDefaultCharacter, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
|
||||
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump,
|
||||
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopPath, JumpHopPlatform,
|
||||
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
||||
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||
@@ -61,15 +62,40 @@ pub(crate) fn map_jump_hop_run_procedure_result(
|
||||
Ok(map_jump_hop_run_snapshot(run))
|
||||
}
|
||||
|
||||
pub(crate) fn map_jump_hop_leaderboard_procedure_result(
|
||||
result: JumpHopLeaderboardProcedureResult,
|
||||
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
Ok(JumpHopLeaderboardResponse {
|
||||
profile_id: result.profile_id,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(map_jump_hop_leaderboard_entry_snapshot)
|
||||
.collect(),
|
||||
viewer_best: result
|
||||
.viewer_best
|
||||
.map(map_jump_hop_leaderboard_entry_snapshot),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn map_jump_hop_gallery_card_view_row(
|
||||
row: JumpHopGalleryCardViewRow,
|
||||
) -> JumpHopGalleryCardResponse {
|
||||
let theme_text = if row.theme_text.trim().is_empty() {
|
||||
row.work_title.clone()
|
||||
} else {
|
||||
row.theme_text.clone()
|
||||
};
|
||||
JumpHopGalleryCardResponse {
|
||||
public_work_code: row.public_work_code,
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
theme_text,
|
||||
work_title: row.work_title,
|
||||
work_description: row.work_description,
|
||||
cover_image_src: empty_string_to_none(row.cover_image_src),
|
||||
@@ -104,15 +130,22 @@ fn map_jump_hop_session_snapshot(
|
||||
fn map_jump_hop_work_snapshot(
|
||||
snapshot: JumpHopWorkSnapshot,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
let theme_text = if snapshot.theme_text.trim().is_empty() {
|
||||
snapshot.work_title.clone()
|
||||
} else {
|
||||
snapshot.theme_text.clone()
|
||||
};
|
||||
let draft = JumpHopDraftResponse {
|
||||
template_id: "jump-hop".to_string(),
|
||||
template_name: "跳一跳".to_string(),
|
||||
profile_id: Some(snapshot.profile_id.clone()),
|
||||
theme_text: theme_text.clone(),
|
||||
work_title: snapshot.work_title.clone(),
|
||||
work_description: snapshot.work_description.clone(),
|
||||
theme_tags: snapshot.theme_tags.clone(),
|
||||
difficulty: parse_difficulty(&snapshot.difficulty),
|
||||
style_preset: parse_style_preset(&snapshot.style_preset),
|
||||
default_character: Some(default_jump_hop_character()),
|
||||
character_prompt: snapshot.character_prompt.clone(),
|
||||
tile_prompt: snapshot.tile_prompt.clone(),
|
||||
end_mood_prompt: snapshot.end_mood_prompt.clone(),
|
||||
@@ -126,6 +159,7 @@ fn map_jump_hop_work_snapshot(
|
||||
.collect(),
|
||||
path: Some(map_jump_hop_path(snapshot.path.clone())),
|
||||
cover_composite: snapshot.cover_composite.clone(),
|
||||
back_button_asset: snapshot.back_button_asset.clone().map(map_character_asset),
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
};
|
||||
let character_asset = draft
|
||||
@@ -143,6 +177,7 @@ fn map_jump_hop_work_snapshot(
|
||||
profile_id: snapshot.profile_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
source_session_id: empty_string_to_none(snapshot.source_session_id),
|
||||
theme_text,
|
||||
work_title: snapshot.work_title,
|
||||
work_description: snapshot.work_description,
|
||||
theme_tags: snapshot.theme_tags,
|
||||
@@ -159,6 +194,7 @@ fn map_jump_hop_work_snapshot(
|
||||
},
|
||||
draft,
|
||||
path: map_jump_hop_path(snapshot.path),
|
||||
default_character: Some(default_jump_hop_character()),
|
||||
character_asset,
|
||||
tile_atlas_asset,
|
||||
tile_assets: snapshot
|
||||
@@ -166,19 +202,27 @@ fn map_jump_hop_work_snapshot(
|
||||
.into_iter()
|
||||
.map(map_tile_asset)
|
||||
.collect(),
|
||||
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
|
||||
let theme_text = if snapshot.theme_text.trim().is_empty() {
|
||||
snapshot.work_title.clone()
|
||||
} else {
|
||||
snapshot.theme_text.clone()
|
||||
};
|
||||
JumpHopDraftResponse {
|
||||
template_id: snapshot.template_id,
|
||||
template_name: snapshot.template_name,
|
||||
profile_id: snapshot.profile_id,
|
||||
theme_text,
|
||||
work_title: snapshot.work_title,
|
||||
work_description: snapshot.work_description,
|
||||
theme_tags: snapshot.theme_tags,
|
||||
difficulty: parse_difficulty(&snapshot.difficulty),
|
||||
style_preset: parse_style_preset(&snapshot.style_preset),
|
||||
default_character: Some(default_jump_hop_character()),
|
||||
character_prompt: snapshot.character_prompt,
|
||||
tile_prompt: snapshot.tile_prompt,
|
||||
end_mood_prompt: snapshot.end_mood_prompt,
|
||||
@@ -191,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe
|
||||
.collect(),
|
||||
path: snapshot.path.map(map_jump_hop_path),
|
||||
cover_composite: snapshot.cover_composite,
|
||||
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
}
|
||||
}
|
||||
@@ -211,10 +256,13 @@ fn map_character_asset(snapshot: JumpHopCharacterAssetSnapshot) -> JumpHopCharac
|
||||
fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset {
|
||||
JumpHopTileAsset {
|
||||
tile_type: parse_tile_type(&snapshot.tile_type),
|
||||
tile_id: snapshot.tile_id,
|
||||
image_src: snapshot.image_src,
|
||||
image_object_key: snapshot.image_object_key,
|
||||
asset_object_id: snapshot.asset_object_id,
|
||||
source_atlas_cell: snapshot.source_atlas_cell,
|
||||
atlas_row: snapshot.atlas_row,
|
||||
atlas_col: snapshot.atlas_col,
|
||||
visual_width: snapshot.visual_width,
|
||||
visual_height: snapshot.visual_height,
|
||||
top_surface_radius: snapshot.top_surface_radius,
|
||||
@@ -263,6 +311,8 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS
|
||||
crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing,
|
||||
},
|
||||
current_platform_index: snapshot.current_platform_index,
|
||||
successful_jump_count: snapshot.current_platform_index,
|
||||
duration_ms: jump_hop_duration_ms(snapshot.started_at_ms, snapshot.finished_at_ms),
|
||||
score: snapshot.score,
|
||||
combo: snapshot.combo,
|
||||
path: map_jump_hop_path(snapshot.path),
|
||||
@@ -286,6 +336,34 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS
|
||||
}
|
||||
}
|
||||
|
||||
fn map_jump_hop_leaderboard_entry_snapshot(
|
||||
snapshot: JumpHopLeaderboardEntrySnapshot,
|
||||
) -> JumpHopLeaderboardEntry {
|
||||
JumpHopLeaderboardEntry {
|
||||
rank: snapshot.rank,
|
||||
player_id: snapshot.player_id,
|
||||
successful_jump_count: snapshot.successful_jump_count,
|
||||
duration_ms: snapshot.duration_ms,
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_jump_hop_character() -> JumpHopDefaultCharacter {
|
||||
JumpHopDefaultCharacter {
|
||||
character_id: "jump-hop-default-runner".to_string(),
|
||||
display_name: "默认角色".to_string(),
|
||||
model_kind: "builtin-three".to_string(),
|
||||
body_color: "#f59e0b".to_string(),
|
||||
accent_color: "#2563eb".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_hop_duration_ms(started_at_ms: u64, finished_at_ms: Option<u64>) -> u64 {
|
||||
finished_at_ms
|
||||
.unwrap_or(started_at_ms)
|
||||
.saturating_sub(started_at_ms)
|
||||
}
|
||||
|
||||
fn parse_difficulty(value: &str) -> JumpHopDifficulty {
|
||||
match value {
|
||||
"easy" => JumpHopDifficulty::Easy,
|
||||
|
||||
@@ -296,26 +296,30 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
event_banners_json: header.event_banners_json,
|
||||
creation_types: creation_types
|
||||
.into_iter()
|
||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: creation_entry_text_or_default(
|
||||
item.category_id,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
|
||||
),
|
||||
category_label: creation_entry_text_or_default(
|
||||
item.category_label,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
|
||||
),
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
.map(|item| {
|
||||
normalize_creation_entry_type_snapshot(
|
||||
module_runtime::CreationEntryTypeSnapshot {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: creation_entry_text_or_default(
|
||||
item.category_id,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
|
||||
),
|
||||
category_label: creation_entry_text_or_default(
|
||||
item.category_label,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
|
||||
),
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
@@ -353,20 +357,22 @@ fn map_creation_entry_config_snapshot(
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
.map(|item| {
|
||||
normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec_json: item.unified_creation_spec_json,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
@@ -380,6 +386,150 @@ fn creation_entry_text_or_default(value: Option<String>, default_value: &str) ->
|
||||
.unwrap_or_else(|| default_value.to_string())
|
||||
}
|
||||
|
||||
fn normalize_creation_entry_type_snapshot(
|
||||
item: module_runtime::CreationEntryTypeSnapshot,
|
||||
) -> module_runtime::CreationEntryTypeSnapshot {
|
||||
// 中文注释:旧库里残留的跳一跳系统默认入口行仍会从订阅缓存命中,这里统一做读模型纠偏,
|
||||
// 这样无论走订阅缓存还是 procedure 回退,创作页都只会看到新的跳一跳入口口径。
|
||||
if item.id == "jump-hop"
|
||||
&& item.title == "跳一跳"
|
||||
&& item.subtitle == "俯视角跳跃闯关"
|
||||
&& item.badge == "可创建"
|
||||
&& item.image_src == "/creation-type-references/puzzle.webp"
|
||||
&& item.visible
|
||||
&& item.open
|
||||
&& item.sort_order == 45
|
||||
{
|
||||
return module_runtime::CreationEntryTypeSnapshot {
|
||||
subtitle: "主题驱动平台跳跃".to_string(),
|
||||
image_src: "/creation-type-references/jump-hop.webp".to_string(),
|
||||
..item
|
||||
};
|
||||
}
|
||||
|
||||
item
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use spacetimedb_sdk::Timestamp;
|
||||
|
||||
fn build_creation_entry_header() -> CreationEntryConfig {
|
||||
CreationEntryConfig {
|
||||
config_id: "creation-entry-config".to_string(),
|
||||
start_title: "新建作品".to_string(),
|
||||
start_description: "选择模板后进入对应的创作表单。".to_string(),
|
||||
start_idle_badge: "模板 Tab".to_string(),
|
||||
start_busy_badge: "正在开启".to_string(),
|
||||
modal_title: "选择创作类型".to_string(),
|
||||
modal_description: "先选玩法类型,再进入对应创作工作台。".to_string(),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000),
|
||||
event_title: None,
|
||||
event_description: None,
|
||||
event_cover_image_src: None,
|
||||
event_prize_pool_mud_points: 0,
|
||||
event_starts_at_text: None,
|
||||
event_ends_at_text: None,
|
||||
event_banners_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_old_jump_hop_row() -> CreationEntryTypeConfig {
|
||||
CreationEntryTypeConfig {
|
||||
id: "jump-hop".to_string(),
|
||||
title: "跳一跳".to_string(),
|
||||
subtitle: "俯视角跳跃闯关".to_string(),
|
||||
badge: "可创建".to_string(),
|
||||
image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
visible: true,
|
||||
open: true,
|
||||
sort_order: 45,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(2_000_000),
|
||||
category_id: Some("recommended".to_string()),
|
||||
category_label: Some("热门推荐".to_string()),
|
||||
category_sort_order: 20,
|
||||
unified_creation_spec_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_creation_entry_config_record_from_rows_normalizes_old_jump_hop_row() {
|
||||
let record = build_creation_entry_config_record_from_rows(
|
||||
build_creation_entry_header(),
|
||||
vec![build_old_jump_hop_row()],
|
||||
);
|
||||
|
||||
let jump_hop = record
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "jump-hop")
|
||||
.expect("should contain jump-hop");
|
||||
|
||||
assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
|
||||
assert_eq!(
|
||||
jump_hop.image_src,
|
||||
"/creation-type-references/jump-hop.webp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_creation_entry_config_snapshot_normalizes_old_jump_hop_snapshot() {
|
||||
let record = map_creation_entry_config_snapshot(CreationEntryConfigSnapshot {
|
||||
config_id: "creation-entry-config".to_string(),
|
||||
start_card: CreationEntryStartCardSnapshot {
|
||||
title: "新建作品".to_string(),
|
||||
description: "选择模板后进入对应的创作表单。".to_string(),
|
||||
idle_badge: "模板 Tab".to_string(),
|
||||
busy_badge: "正在开启".to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalSnapshot {
|
||||
title: "选择创作类型".to_string(),
|
||||
description: "先选玩法类型,再进入对应创作工作台。".to_string(),
|
||||
},
|
||||
event_banner: CreationEntryEventBannerSnapshot {
|
||||
title: "主题创作赛".to_string(),
|
||||
description: "用温暖的色彩,捏出秋天的故事。".to_string(),
|
||||
cover_image_src: "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png".to_string(),
|
||||
prize_pool_mud_points: 58_000,
|
||||
starts_at_text: "2024.10.20 10:00".to_string(),
|
||||
ends_at_text: "2024.11.20 23:59".to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners_json: None,
|
||||
creation_types: vec![CreationEntryTypeSnapshot {
|
||||
id: "jump-hop".to_string(),
|
||||
title: "跳一跳".to_string(),
|
||||
subtitle: "俯视角跳跃闯关".to_string(),
|
||||
badge: "可创建".to_string(),
|
||||
image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
visible: true,
|
||||
open: true,
|
||||
sort_order: 45,
|
||||
category_id: "recommended".to_string(),
|
||||
category_label: "热门推荐".to_string(),
|
||||
category_sort_order: 20,
|
||||
updated_at_micros: 2_000_000,
|
||||
unified_creation_spec_json: None,
|
||||
}],
|
||||
updated_at_micros: 1_000_000,
|
||||
});
|
||||
|
||||
let jump_hop = record
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "jump-hop")
|
||||
.expect("should contain jump-hop");
|
||||
|
||||
assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
|
||||
assert_eq!(
|
||||
jump_hop.image_src,
|
||||
"/creation-type-references/jump-hop.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_setting_procedure_result(
|
||||
result: RuntimeSettingProcedureResult,
|
||||
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {
|
||||
|
||||
@@ -126,6 +126,7 @@ pub mod bark_battle_runtime_run_row_type;
|
||||
pub mod bark_battle_runtime_run_table;
|
||||
pub mod bark_battle_score_record_row_type;
|
||||
pub mod bark_battle_score_record_table;
|
||||
pub mod bark_battle_work_delete_input_type;
|
||||
pub mod bark_battle_work_publish_input_type;
|
||||
pub mod bark_battle_work_stats_projection_row_type;
|
||||
pub mod bark_battle_work_stats_projection_table;
|
||||
@@ -328,14 +329,17 @@ pub mod database_migration_procedure_result_type;
|
||||
pub mod database_migration_revoke_operator_input_type;
|
||||
pub mod database_migration_table_stat_type;
|
||||
pub mod database_migration_warning_type;
|
||||
pub mod delete_bark_battle_work_procedure;
|
||||
pub mod delete_big_fish_work_procedure;
|
||||
pub mod delete_custom_world_agent_session_procedure;
|
||||
pub mod delete_custom_world_profile_and_return_procedure;
|
||||
pub mod delete_jump_hop_work_procedure;
|
||||
pub mod delete_match_3_d_work_procedure;
|
||||
pub mod delete_puzzle_work_procedure;
|
||||
pub mod delete_runtime_snapshot_and_return_procedure;
|
||||
pub mod delete_square_hole_work_procedure;
|
||||
pub mod delete_visual_novel_work_procedure;
|
||||
pub mod delete_wooden_fish_work_procedure;
|
||||
pub mod drag_puzzle_piece_or_group_procedure;
|
||||
pub mod drop_square_hole_shape_procedure;
|
||||
pub mod ensure_analytics_date_dimension_for_date_reducer;
|
||||
@@ -369,6 +373,7 @@ pub mod get_custom_world_gallery_detail_by_code_procedure;
|
||||
pub mod get_custom_world_gallery_detail_procedure;
|
||||
pub mod get_custom_world_library_detail_procedure;
|
||||
pub mod get_jump_hop_agent_session_procedure;
|
||||
pub mod get_jump_hop_leaderboard_procedure;
|
||||
pub mod get_jump_hop_run_procedure;
|
||||
pub mod get_jump_hop_work_profile_procedure;
|
||||
pub mod get_match_3_d_agent_session_procedure;
|
||||
@@ -440,6 +445,11 @@ pub mod jump_hop_gallery_view_table;
|
||||
pub mod jump_hop_jump_procedure;
|
||||
pub mod jump_hop_jump_result_kind_type;
|
||||
pub mod jump_hop_last_jump_type;
|
||||
pub mod jump_hop_leaderboard_entry_row_type;
|
||||
pub mod jump_hop_leaderboard_entry_snapshot_type;
|
||||
pub mod jump_hop_leaderboard_entry_table;
|
||||
pub mod jump_hop_leaderboard_get_input_type;
|
||||
pub mod jump_hop_leaderboard_procedure_result_type;
|
||||
pub mod jump_hop_path_type;
|
||||
pub mod jump_hop_platform_type;
|
||||
pub mod jump_hop_run_get_input_type;
|
||||
@@ -454,6 +464,7 @@ pub mod jump_hop_runtime_run_table;
|
||||
pub mod jump_hop_scoring_type;
|
||||
pub mod jump_hop_tile_asset_snapshot_type;
|
||||
pub mod jump_hop_tile_type_type;
|
||||
pub mod jump_hop_work_delete_input_type;
|
||||
pub mod jump_hop_work_get_input_type;
|
||||
pub mod jump_hop_work_procedure_result_type;
|
||||
pub mod jump_hop_work_profile_row_type;
|
||||
@@ -1088,6 +1099,7 @@ pub mod wooden_fish_run_status_type;
|
||||
pub mod wooden_fish_runtime_run_row_type;
|
||||
pub mod wooden_fish_runtime_run_table;
|
||||
pub mod wooden_fish_word_counter_type;
|
||||
pub mod wooden_fish_work_delete_input_type;
|
||||
pub mod wooden_fish_work_get_input_type;
|
||||
pub mod wooden_fish_work_procedure_result_type;
|
||||
pub mod wooden_fish_work_profile_row_type;
|
||||
@@ -1218,6 +1230,7 @@ pub use bark_battle_runtime_run_row_type::BarkBattleRuntimeRunRow;
|
||||
pub use bark_battle_runtime_run_table::*;
|
||||
pub use bark_battle_score_record_row_type::BarkBattleScoreRecordRow;
|
||||
pub use bark_battle_score_record_table::*;
|
||||
pub use bark_battle_work_delete_input_type::BarkBattleWorkDeleteInput;
|
||||
pub use bark_battle_work_publish_input_type::BarkBattleWorkPublishInput;
|
||||
pub use bark_battle_work_stats_projection_row_type::BarkBattleWorkStatsProjectionRow;
|
||||
pub use bark_battle_work_stats_projection_table::*;
|
||||
@@ -1420,14 +1433,17 @@ pub use database_migration_procedure_result_type::DatabaseMigrationProcedureResu
|
||||
pub use database_migration_revoke_operator_input_type::DatabaseMigrationRevokeOperatorInput;
|
||||
pub use database_migration_table_stat_type::DatabaseMigrationTableStat;
|
||||
pub use database_migration_warning_type::DatabaseMigrationWarning;
|
||||
pub use delete_bark_battle_work_procedure::delete_bark_battle_work;
|
||||
pub use delete_big_fish_work_procedure::delete_big_fish_work;
|
||||
pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session;
|
||||
pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return;
|
||||
pub use delete_jump_hop_work_procedure::delete_jump_hop_work;
|
||||
pub use delete_match_3_d_work_procedure::delete_match_3_d_work;
|
||||
pub use delete_puzzle_work_procedure::delete_puzzle_work;
|
||||
pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return;
|
||||
pub use delete_square_hole_work_procedure::delete_square_hole_work;
|
||||
pub use delete_visual_novel_work_procedure::delete_visual_novel_work;
|
||||
pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work;
|
||||
pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group;
|
||||
pub use drop_square_hole_shape_procedure::drop_square_hole_shape;
|
||||
pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date;
|
||||
@@ -1461,6 +1477,7 @@ pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gall
|
||||
pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail;
|
||||
pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail;
|
||||
pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session;
|
||||
pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard;
|
||||
pub use get_jump_hop_run_procedure::get_jump_hop_run;
|
||||
pub use get_jump_hop_work_profile_procedure::get_jump_hop_work_profile;
|
||||
pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session;
|
||||
@@ -1532,6 +1549,11 @@ pub use jump_hop_gallery_view_table::*;
|
||||
pub use jump_hop_jump_procedure::jump_hop_jump;
|
||||
pub use jump_hop_jump_result_kind_type::JumpHopJumpResultKind;
|
||||
pub use jump_hop_last_jump_type::JumpHopLastJump;
|
||||
pub use jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow;
|
||||
pub use jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot;
|
||||
pub use jump_hop_leaderboard_entry_table::*;
|
||||
pub use jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput;
|
||||
pub use jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult;
|
||||
pub use jump_hop_path_type::JumpHopPath;
|
||||
pub use jump_hop_platform_type::JumpHopPlatform;
|
||||
pub use jump_hop_run_get_input_type::JumpHopRunGetInput;
|
||||
@@ -1546,6 +1568,7 @@ pub use jump_hop_runtime_run_table::*;
|
||||
pub use jump_hop_scoring_type::JumpHopScoring;
|
||||
pub use jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot;
|
||||
pub use jump_hop_tile_type_type::JumpHopTileType;
|
||||
pub use jump_hop_work_delete_input_type::JumpHopWorkDeleteInput;
|
||||
pub use jump_hop_work_get_input_type::JumpHopWorkGetInput;
|
||||
pub use jump_hop_work_procedure_result_type::JumpHopWorkProcedureResult;
|
||||
pub use jump_hop_work_profile_row_type::JumpHopWorkProfileRow;
|
||||
@@ -2180,6 +2203,7 @@ pub use wooden_fish_run_status_type::WoodenFishRunStatus;
|
||||
pub use wooden_fish_runtime_run_row_type::WoodenFishRuntimeRunRow;
|
||||
pub use wooden_fish_runtime_run_table::*;
|
||||
pub use wooden_fish_word_counter_type::WoodenFishWordCounter;
|
||||
pub use wooden_fish_work_delete_input_type::WoodenFishWorkDeleteInput;
|
||||
pub use wooden_fish_work_get_input_type::WoodenFishWorkGetInput;
|
||||
pub use wooden_fish_work_procedure_result_type::WoodenFishWorkProcedureResult;
|
||||
pub use wooden_fish_work_profile_row_type::WoodenFishWorkProfileRow;
|
||||
@@ -2506,6 +2530,7 @@ pub struct DbUpdate {
|
||||
jump_hop_event: __sdk::TableUpdate<JumpHopEventRow>,
|
||||
jump_hop_gallery_card_view: __sdk::TableUpdate<JumpHopGalleryCardViewRow>,
|
||||
jump_hop_gallery_view: __sdk::TableUpdate<JumpHopGalleryViewRow>,
|
||||
jump_hop_leaderboard_entry: __sdk::TableUpdate<JumpHopLeaderboardEntryRow>,
|
||||
jump_hop_runtime_run: __sdk::TableUpdate<JumpHopRuntimeRunRow>,
|
||||
jump_hop_work_profile: __sdk::TableUpdate<JumpHopWorkProfileRow>,
|
||||
match_3_d_agent_message: __sdk::TableUpdate<Match3DAgentMessageRow>,
|
||||
@@ -2726,6 +2751,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
"jump_hop_gallery_view" => db_update.jump_hop_gallery_view.append(
|
||||
jump_hop_gallery_view_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"jump_hop_leaderboard_entry" => db_update.jump_hop_leaderboard_entry.append(
|
||||
jump_hop_leaderboard_entry_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"jump_hop_runtime_run" => db_update.jump_hop_runtime_run.append(
|
||||
jump_hop_runtime_run_table::parse_table_update(table_update)?,
|
||||
),
|
||||
@@ -3175,6 +3203,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
diff.jump_hop_event = cache
|
||||
.apply_diff_to_table::<JumpHopEventRow>("jump_hop_event", &self.jump_hop_event)
|
||||
.with_updates_by_pk(|row| &row.event_id);
|
||||
diff.jump_hop_leaderboard_entry = cache
|
||||
.apply_diff_to_table::<JumpHopLeaderboardEntryRow>(
|
||||
"jump_hop_leaderboard_entry",
|
||||
&self.jump_hop_leaderboard_entry,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.entry_id);
|
||||
diff.jump_hop_runtime_run = cache
|
||||
.apply_diff_to_table::<JumpHopRuntimeRunRow>(
|
||||
"jump_hop_runtime_run",
|
||||
@@ -3693,6 +3727,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"jump_hop_gallery_view" => db_update
|
||||
.jump_hop_gallery_view
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"jump_hop_leaderboard_entry" => db_update
|
||||
.jump_hop_leaderboard_entry
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"jump_hop_runtime_run" => db_update
|
||||
.jump_hop_runtime_run
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
@@ -4054,6 +4091,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"jump_hop_gallery_view" => db_update
|
||||
.jump_hop_gallery_view
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"jump_hop_leaderboard_entry" => db_update
|
||||
.jump_hop_leaderboard_entry
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"jump_hop_runtime_run" => db_update
|
||||
.jump_hop_runtime_run
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
@@ -4331,6 +4371,7 @@ pub struct AppliedDiff<'r> {
|
||||
jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>,
|
||||
jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>,
|
||||
jump_hop_gallery_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryViewRow>,
|
||||
jump_hop_leaderboard_entry: __sdk::TableAppliedDiff<'r, JumpHopLeaderboardEntryRow>,
|
||||
jump_hop_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>,
|
||||
jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>,
|
||||
match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>,
|
||||
@@ -4629,6 +4670,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.jump_hop_gallery_view,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<JumpHopLeaderboardEntryRow>(
|
||||
"jump_hop_leaderboard_entry",
|
||||
&self.jump_hop_leaderboard_entry,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<JumpHopRuntimeRunRow>(
|
||||
"jump_hop_runtime_run",
|
||||
&self.jump_hop_runtime_run,
|
||||
@@ -5232,19 +5278,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle {
|
||||
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
|
||||
pub trait RemoteDbContext:
|
||||
__sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
{
|
||||
}
|
||||
impl<
|
||||
Ctx: __sdk::DbContext<
|
||||
Ctx: __sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>,
|
||||
> RemoteDbContext for Ctx
|
||||
> RemoteDbContext for Ctx
|
||||
{
|
||||
}
|
||||
|
||||
@@ -5681,6 +5727,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
jump_hop_event_table::register_table(client_cache);
|
||||
jump_hop_gallery_card_view_table::register_table(client_cache);
|
||||
jump_hop_gallery_view_table::register_table(client_cache);
|
||||
jump_hop_leaderboard_entry_table::register_table(client_cache);
|
||||
jump_hop_runtime_run_table::register_table(client_cache);
|
||||
jump_hop_work_profile_table::register_table(client_cache);
|
||||
match_3_d_agent_message_table::register_table(client_cache);
|
||||
@@ -5799,6 +5846,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"jump_hop_event",
|
||||
"jump_hop_gallery_card_view",
|
||||
"jump_hop_gallery_view",
|
||||
"jump_hop_leaderboard_entry",
|
||||
"jump_hop_runtime_run",
|
||||
"jump_hop_work_profile",
|
||||
"match_3_d_agent_message",
|
||||
|
||||
@@ -47,9 +47,11 @@ pub trait accept_quest {
|
||||
&self,
|
||||
input: QuestRecordInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -58,9 +60,11 @@ impl accept_quest for super::RemoteReducers {
|
||||
&self,
|
||||
input: QuestRecordInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AcceptQuestArgs { input }, callback)
|
||||
|
||||
@@ -47,9 +47,11 @@ pub trait acknowledge_quest_completion {
|
||||
&self,
|
||||
input: QuestCompletionAckInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -58,9 +60,11 @@ impl acknowledge_quest_completion for super::RemoteReducers {
|
||||
&self,
|
||||
input: QuestCompletionAckInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
|
||||
input: RuntimeProfileTaskConfigAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_profile_invite_codes {
|
||||
input: RuntimeProfileInviteCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_profile_invite_codes for super::RemoteProcedures {
|
||||
input: RuntimeProfileInviteCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminListProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait admin_list_profile_recharge_products {
|
||||
input: RuntimeProfileRechargeProductAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl admin_list_profile_recharge_products for super::RemoteProcedures {
|
||||
input: RuntimeProfileRechargeProductAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminListProcedureResult>(
|
||||
"admin_list_profile_recharge_products",
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_profile_redeem_codes {
|
||||
input: RuntimeProfileRedeemCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_profile_redeem_codes for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminListProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_profile_task_configs {
|
||||
input: RuntimeProfileTaskConfigAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_profile_task_configs for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminListProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_list_work_visibility {
|
||||
input: AdminWorkVisibilityListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_list_work_visibility for super::RemoteProcedures {
|
||||
input: AdminWorkVisibilityListInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AdminWorkVisibilityListProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_update_work_visibility {
|
||||
input: AdminWorkVisibilityUpdateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_update_work_visibility for super::RemoteProcedures {
|
||||
input: AdminWorkVisibilityUpdateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AdminWorkVisibilityProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_invite_code {
|
||||
input: RuntimeProfileInviteCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_upsert_profile_invite_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileInviteCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait admin_upsert_profile_recharge_product {
|
||||
input: RuntimeProfileRechargeProductAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl admin_upsert_profile_recharge_product for super::RemoteProcedures {
|
||||
input: RuntimeProfileRechargeProductAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_redeem_code {
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_upsert_profile_redeem_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_task_config {
|
||||
input: RuntimeProfileTaskConfigAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_upsert_profile_task_config for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait advance_puzzle_next_level {
|
||||
input: PuzzleRunNextLevelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl advance_puzzle_next_level for super::RemoteProcedures {
|
||||
input: PuzzleRunNextLevelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait append_ai_text_chunk_and_return {
|
||||
input: AiTextChunkAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl append_ai_text_chunk_and_return for super::RemoteProcedures {
|
||||
input: AiTextChunkAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait append_visual_novel_runtime_history_entry {
|
||||
input: VisualNovelRuntimeHistoryAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl append_visual_novel_runtime_history_entry for super::RemoteProcedures {
|
||||
input: VisualNovelRuntimeHistoryAppendInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, VisualNovelHistoryProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait apply_chapter_progression_ledger_entry_and_return {
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl apply_chapter_progression_ledger_entry_and_return for super::RemoteProcedur
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>(
|
||||
|
||||
@@ -50,9 +50,11 @@ pub trait apply_chapter_progression_ledger_entry {
|
||||
&self,
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -61,9 +63,11 @@ impl apply_chapter_progression_ledger_entry for super::RemoteReducers {
|
||||
&self,
|
||||
input: ChapterProgressionLedgerInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp.invoke_reducer_with_callback(
|
||||
ApplyChapterProgressionLedgerEntryArgs { input },
|
||||
|
||||
@@ -47,9 +47,11 @@ pub trait apply_inventory_mutation {
|
||||
&self,
|
||||
input: InventoryMutationInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -58,9 +60,11 @@ impl apply_inventory_mutation for super::RemoteReducers {
|
||||
&self,
|
||||
input: InventoryMutationInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(ApplyInventoryMutationArgs { input }, callback)
|
||||
|
||||
@@ -47,9 +47,11 @@ pub trait apply_quest_signal {
|
||||
&self,
|
||||
input: QuestSignalApplyInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -58,9 +60,11 @@ impl apply_quest_signal for super::RemoteReducers {
|
||||
&self,
|
||||
input: QuestSignalApplyInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(ApplyQuestSignalArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait attach_ai_result_reference_and_return {
|
||||
input: AiResultReferenceInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl attach_ai_result_reference_and_return for super::RemoteProcedures {
|
||||
input: AiResultReferenceInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait authorize_database_migration_operator {
|
||||
input: DatabaseMigrationAuthorizeOperatorInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl authorize_database_migration_operator for super::RemoteProcedures {
|
||||
input: DatabaseMigrationAuthorizeOperatorInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationOperatorProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, DatabaseMigrationOperatorProcedureResult>(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct BarkBattleWorkDeleteInput {
|
||||
pub work_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for BarkBattleWorkDeleteInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -31,10 +31,10 @@ pub trait begin_story_session_and_return {
|
||||
input: StorySessionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl begin_story_session_and_return for super::RemoteProcedures {
|
||||
input: StorySessionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<StorySessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, StorySessionProcedureResult>(
|
||||
|
||||
@@ -47,9 +47,11 @@ pub trait begin_story_session {
|
||||
&self,
|
||||
input: StorySessionInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -58,9 +60,11 @@ impl begin_story_session for super::RemoteReducers {
|
||||
&self,
|
||||
input: StorySessionInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(BeginStorySessionArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait bind_asset_object_to_entity_and_return {
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl bind_asset_object_to_entity_and_return for super::RemoteProcedures {
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetEntityBindingProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AssetEntityBindingProcedureResult>(
|
||||
|
||||
@@ -47,9 +47,11 @@ pub trait bind_asset_object_to_entity {
|
||||
&self,
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
@@ -58,9 +60,11 @@ impl bind_asset_object_to_entity for super::RemoteReducers {
|
||||
&self,
|
||||
input: AssetEntityBindingInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
callback: impl FnOnce(
|
||||
&super::ReducerEventContext,
|
||||
Result<Result<(), String>, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(BindAssetObjectToEntityArgs { input }, callback)
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait cancel_ai_task_and_return {
|
||||
input: AiTaskCancelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl cancel_ai_task_and_return for super::RemoteProcedures {
|
||||
input: AiTaskCancelInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait checkpoint_wooden_fish_run {
|
||||
input: WoodenFishRunCheckpointInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl checkpoint_wooden_fish_run for super::RemoteProcedures {
|
||||
input: WoodenFishRunCheckpointInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishRunProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, WoodenFishRunProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait claim_profile_task_reward_and_return {
|
||||
input: RuntimeProfileTaskClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl claim_profile_task_reward_and_return for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskClaimProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait claim_puzzle_work_point_incentive {
|
||||
input: PuzzleWorkPointIncentiveClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl claim_puzzle_work_point_incentive for super::RemoteProcedures {
|
||||
input: PuzzleWorkPointIncentiveClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleWorkProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait clear_database_migration_import_chunks {
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl clear_database_migration_import_chunks for super::RemoteProcedures {
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<DatabaseMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait clear_platform_browse_history_and_return {
|
||||
input: RuntimeBrowseHistoryClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl clear_platform_browse_history_and_return for super::RemoteProcedures {
|
||||
input: RuntimeBrowseHistoryClearInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeBrowseHistoryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeBrowseHistoryProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait click_match_3_d_item {
|
||||
input: Match3DRunClickInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl click_match_3_d_item for super::RemoteProcedures {
|
||||
input: Match3DRunClickInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DClickItemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, Match3DClickItemProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_big_fish_draft {
|
||||
input: BigFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_big_fish_draft for super::RemoteProcedures {
|
||||
input: BigFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>(
|
||||
|
||||
@@ -34,10 +34,10 @@ pub trait compile_custom_world_published_profile {
|
||||
input: CustomWorldPublishedProfileCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl compile_custom_world_published_profile for super::RemoteProcedures {
|
||||
input: CustomWorldPublishedProfileCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldPublishedProfileCompileResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CustomWorldPublishedProfileCompileResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_jump_hop_draft {
|
||||
input: JumpHopDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_jump_hop_draft for super::RemoteProcedures {
|
||||
input: JumpHopDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, JumpHopAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_match_3_d_draft {
|
||||
input: Match3DDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_match_3_d_draft for super::RemoteProcedures {
|
||||
input: Match3DDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_puzzle_agent_draft {
|
||||
input: PuzzleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_puzzle_agent_draft for super::RemoteProcedures {
|
||||
input: PuzzleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_square_hole_draft {
|
||||
input: SquareHoleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_square_hole_draft for super::RemoteProcedures {
|
||||
input: SquareHoleDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, SquareHoleAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_visual_novel_work_profile {
|
||||
input: VisualNovelWorkCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_visual_novel_work_profile for super::RemoteProcedures {
|
||||
input: VisualNovelWorkCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<VisualNovelAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait compile_wooden_fish_draft {
|
||||
input: WoodenFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl compile_wooden_fish_draft for super::RemoteProcedures {
|
||||
input: WoodenFishDraftCompileInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<WoodenFishAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, WoodenFishAgentSessionProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait complete_ai_stage_and_return {
|
||||
input: AiStageCompletionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl complete_ai_stage_and_return for super::RemoteProcedures {
|
||||
input: AiStageCompletionInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait complete_ai_task_and_return {
|
||||
input: AiTaskFinishInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl complete_ai_task_and_return for super::RemoteProcedures {
|
||||
input: AiTaskFinishInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AiTaskProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait confirm_asset_object_and_return {
|
||||
input: AssetObjectUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl confirm_asset_object_and_return for super::RemoteProcedures {
|
||||
input: AssetObjectUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<AssetObjectProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AssetObjectProcedureResult>(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user