新增排行榜 displayName 契约并在 api-server 出口补齐展示名 调整跳一跳结果页和运行态排行榜只显示 displayName 补充禁止展示 user_id 的前后端回归测试 更新跳一跳 PRD、后端契约文档和 Hermes 决策记录
1990 lines
85 KiB
Rust
1990 lines
85 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
http::{HeaderName, StatusCode, header},
|
||
response::Response,
|
||
};
|
||
use module_assets::{
|
||
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||
generate_asset_binding_id, generate_asset_object_id,
|
||
};
|
||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::jump_hop::{
|
||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||
JumpHopRunResponse,
|
||
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
||
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||
};
|
||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||
use spacetime_client::SpacetimeClientError;
|
||
use std::{
|
||
collections::{BTreeMap, VecDeque},
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||
generated_asset_sheets::{
|
||
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options,
|
||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||
},
|
||
generated_image_assets::{
|
||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||
normalize_generated_image_asset_mime,
|
||
},
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
build_openai_image_http_client, create_openai_image_generation,
|
||
require_openai_image_settings,
|
||
},
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const JUMP_HOP_TILE_ITEM_COUNT: usize = 25;
|
||
|
||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
||
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
||
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5;
|
||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5;
|
||
const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
|
||
const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
|
||
const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
|
||
const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
|
||
const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
|
||
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
|
||
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct JumpHopTileAtlasSlice {
|
||
tile_type: JumpHopTileType,
|
||
source_atlas_cell: String,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
pub async fn create_jump_hop_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<JumpHopWorkspaceCreateRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
||
validate_workspace_request(&request_context, &payload)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session_id = build_prefixed_uuid_id("jump-hop-session-");
|
||
let now = current_utc_micros();
|
||
let draft = build_jump_hop_draft(&payload);
|
||
let session = JumpHopSessionSnapshotResponse {
|
||
session_id,
|
||
owner_user_id,
|
||
status: JumpHopGenerationStatus::Draft,
|
||
draft: Some(draft),
|
||
created_at: format_timestamp_micros(now),
|
||
updated_at: format_timestamp_micros(now),
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopSessionResponse {
|
||
session: state
|
||
.spacetime_client()
|
||
.create_jump_hop_session(session)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_session(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_jump_hop_session(session_id, owner_user_id)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn execute_jump_hop_action(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<JumpHopActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let mut payload = payload;
|
||
maybe_generate_jump_hop_assets(
|
||
&state,
|
||
&request_context,
|
||
session_id.as_str(),
|
||
owner_user_id.as_str(),
|
||
&mut payload,
|
||
)
|
||
.await?;
|
||
let response = state
|
||
.spacetime_client()
|
||
.execute_jump_hop_action(session_id, owner_user_id, payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(Some(&request_context), response))
|
||
}
|
||
|
||
pub async fn publish_jump_hop_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 work = state
|
||
.spacetime_client()
|
||
.publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkMutationResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn list_jump_hop_works(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let works = state
|
||
.spacetime_client()
|
||
.list_jump_hop_works(authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_work_detail(
|
||
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 work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_work_profile(
|
||
profile_id,
|
||
authenticated.claims().user_id().to_string(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn delete_jump_hop_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_jump_hop_work(
|
||
profile_id,
|
||
authenticated.claims().user_id().to_string(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_runtime_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_runtime_work(profile_id)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_leaderboard(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let leaderboard = state
|
||
.spacetime_client()
|
||
.get_jump_hop_leaderboard(profile_id, principal.subject().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopLeaderboardResponse {
|
||
profile_id: leaderboard.profile_id,
|
||
items: leaderboard
|
||
.items
|
||
.into_iter()
|
||
.map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry))
|
||
.collect(),
|
||
viewer_best: leaderboard
|
||
.viewer_best
|
||
.map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn start_jump_hop_run(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
||
let is_draft_runtime = payload
|
||
.runtime_mode
|
||
.as_deref()
|
||
.is_some_and(is_jump_hop_draft_runtime_mode);
|
||
let owner_user_id = principal.subject().to_string();
|
||
let principal_kind = principal.kind().as_str();
|
||
let run = state
|
||
.spacetime_client()
|
||
.start_jump_hop_run(payload, owner_user_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
if !is_draft_runtime {
|
||
record_work_play_start_after_success(
|
||
&state,
|
||
&request_context,
|
||
build_jump_hop_work_play_tracking_draft(
|
||
&principal,
|
||
run.profile_id.clone(),
|
||
JUMP_HOP_RUNTIME_RUNS_ROUTE,
|
||
)
|
||
.owner_user_id(run.owner_user_id.clone())
|
||
.run_id(run.run_id.clone())
|
||
.profile_id(run.profile_id.clone())
|
||
.extra(json!({
|
||
"runStatus": run.status,
|
||
"principalKind": principal_kind,
|
||
})),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn jump_hop_run_jump(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let run = state
|
||
.spacetime_client()
|
||
.jump_hop_run_jump(run_id, owner_user_id, payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopJumpResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn restart_jump_hop_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let run = state
|
||
.spacetime_client()
|
||
.restart_jump_hop_run(run_id, owner_user_id, payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn list_jump_hop_gallery(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let gallery = state
|
||
.spacetime_client()
|
||
.list_jump_hop_gallery()
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(Some(&request_context), gallery))
|
||
}
|
||
|
||
pub async fn get_jump_hop_gallery_detail(
|
||
State(state): State<AppState>,
|
||
Path(public_work_code): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
|
||
let work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_gallery_detail(public_work_code)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopGalleryDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
async fn maybe_generate_jump_hop_assets(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
_session_id: &str,
|
||
owner_user_id: &str,
|
||
payload: &mut JumpHopActionRequest,
|
||
) -> Result<(), Response> {
|
||
if !matches!(
|
||
payload.action_type,
|
||
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
|
||
) {
|
||
return Ok(());
|
||
}
|
||
let has_complete_tile_assets = payload.tile_atlas_asset.is_some()
|
||
&& payload
|
||
.tile_assets
|
||
.as_ref()
|
||
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT);
|
||
let has_real_background = payload
|
||
.cover_composite
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
|
||
let has_back_button_asset = payload
|
||
.back_button_asset
|
||
.as_ref()
|
||
.is_some_and(is_jump_hop_image_asset_usable);
|
||
|
||
if has_complete_tile_assets && has_real_background && has_back_button_asset {
|
||
return Ok(());
|
||
}
|
||
let profile_id = payload
|
||
.profile_id
|
||
.as_ref()
|
||
.map(|value| value.trim())
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToString::to_string)
|
||
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
|
||
payload.profile_id = Some(profile_id.clone());
|
||
|
||
let settings = require_openai_image_settings(state)
|
||
.map(|settings| {
|
||
settings.with_external_api_audit_context(
|
||
request_context,
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.clone()),
|
||
)
|
||
})
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
|
||
let theme_text = payload
|
||
.theme_text
|
||
.as_deref()
|
||
.or(payload.work_title.as_deref())
|
||
.unwrap_or("跳一跳")
|
||
.to_string();
|
||
let tile_prompt = payload
|
||
.tile_prompt
|
||
.clone()
|
||
.unwrap_or_else(|| theme_text.clone());
|
||
|
||
if !has_real_background {
|
||
let background_prompt = build_jump_hop_background_prompt(theme_text.as_str());
|
||
let background_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
background_prompt.as_str(),
|
||
Some(build_jump_hop_background_negative_prompt()),
|
||
JUMP_HOP_BACKGROUND_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳背景底图生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let background_image = background_generated
|
||
.images
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "跳一跳背景底图生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
let background_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"background",
|
||
background_prompt.as_str(),
|
||
background_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_BACKGROUND_IMAGE_WIDTH,
|
||
JUMP_HOP_BACKGROUND_IMAGE_HEIGHT,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
payload.cover_composite = Some(background_asset.image_src);
|
||
}
|
||
|
||
if !has_back_button_asset {
|
||
let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str());
|
||
let back_button_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
back_button_prompt.as_str(),
|
||
Some(build_jump_hop_back_button_negative_prompt()),
|
||
JUMP_HOP_BACK_BUTTON_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳返回按钮图生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let back_button_image =
|
||
back_button_generated
|
||
.images
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "跳一跳返回按钮图生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
let back_button_image =
|
||
prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图")
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let back_button_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"back-button",
|
||
back_button_prompt.as_str(),
|
||
back_button_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH,
|
||
JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
payload.back_button_asset = Some(back_button_asset);
|
||
}
|
||
|
||
if !has_complete_tile_assets {
|
||
let sheet_prompt =
|
||
build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
|
||
let tile_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
sheet_prompt.as_str(),
|
||
Some(build_jump_hop_tile_atlas_negative_prompt()),
|
||
"1024*1024",
|
||
1,
|
||
&[],
|
||
"跳一跳地块图集生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "跳一跳地块图集生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"tile-atlas",
|
||
tile_prompt.as_str(),
|
||
tile_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
1024,
|
||
1024,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let mut tile_assets = Vec::with_capacity(tile_slices.len());
|
||
for (index, tile_slice) in tile_slices.into_iter().enumerate() {
|
||
tile_assets.push(
|
||
persist_jump_hop_tile_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
index,
|
||
tile_slice,
|
||
request_context,
|
||
)
|
||
.await?,
|
||
);
|
||
}
|
||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||
payload.tile_assets = Some(tile_assets);
|
||
}
|
||
if payload.character_asset.is_none() {
|
||
payload.character_asset = Some(build_jump_hop_default_character_asset(
|
||
profile_id.as_str(),
|
||
theme_text.as_str(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
|
||
let value = value.trim();
|
||
value.starts_with("/generated-jump-hop-assets/")
|
||
&& (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
|
||
}
|
||
|
||
fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool {
|
||
!asset.image_src.trim().is_empty()
|
||
&& !asset.image_object_key.trim().is_empty()
|
||
&& !asset.asset_object_id.trim().is_empty()
|
||
&& !asset.generation_provider.trim().is_empty()
|
||
}
|
||
|
||
fn prepare_jump_hop_green_screen_image_for_persist(
|
||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||
failure_label: &str,
|
||
) -> Result<crate::openai_image_generation::DownloadedOpenAiImage, AppError> {
|
||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("{failure_label}解码失败:{error}"),
|
||
}))
|
||
})?;
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("{failure_label}绿幕去背失败:{error}"),
|
||
}))
|
||
})?;
|
||
|
||
Ok(crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: encoded.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
})
|
||
}
|
||
|
||
fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String {
|
||
let theme_text = theme_text.trim();
|
||
if theme_text.is_empty() {
|
||
return "跳一跳".to_string();
|
||
}
|
||
|
||
replace_jump_hop_pokemon_prompt_terms(theme_text)
|
||
}
|
||
|
||
fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String {
|
||
let mut value = value.trim().to_string();
|
||
if value.is_empty() {
|
||
return value;
|
||
}
|
||
|
||
// 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。
|
||
const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [
|
||
("宝可梦", "原创幻想萌宠冒险道具"),
|
||
("神奇宝贝", "原创幻想萌宠冒险道具"),
|
||
("口袋妖怪", "原创幻想萌宠冒险道具"),
|
||
("精灵球", "彩色冒险能量球"),
|
||
("皮卡丘", "黄色闪电萌宠符号"),
|
||
("Pokémon", "原创幻想萌宠冒险道具"),
|
||
("Pokemon", "原创幻想萌宠冒险道具"),
|
||
("POKEMON", "原创幻想萌宠冒险道具"),
|
||
("pokemon", "原创幻想萌宠冒险道具"),
|
||
("Pikachu", "黄色闪电萌宠符号"),
|
||
("PIKACHU", "黄色闪电萌宠符号"),
|
||
("pikachu", "黄色闪电萌宠符号"),
|
||
("Poké Ball", "彩色冒险能量球"),
|
||
("Poke Ball", "彩色冒险能量球"),
|
||
("pokeball", "彩色冒险能量球"),
|
||
];
|
||
|
||
for (from, to) in POKEMON_REPLACEMENTS {
|
||
value = value.replace(from, to);
|
||
}
|
||
|
||
value
|
||
}
|
||
|
||
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
|
||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||
|
||
format!(
|
||
"生成一张9:16竖版跳一跳游戏背景底图,主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画面底部延伸到上方;该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线,禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感,但元素数量必须少,不能抢跳板、角色和交互层的视觉;两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图,不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然,哑光手绘质感,柔和光照,主体背景不油亮、不厚重CG、不暗黑;中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective."
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_background_negative_prompt() -> &'static str {
|
||
"文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
|
||
}
|
||
|
||
fn build_jump_hop_back_button_prompt(theme_text: &str) -> String {
|
||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||
|
||
format!(
|
||
"生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal."
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_back_button_negative_prompt() -> &'static str {
|
||
"文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起"
|
||
}
|
||
|
||
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
|
||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||
let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
|
||
let subject_text = if sanitized_tile_prompt.is_empty() {
|
||
theme_text.as_str()
|
||
} else {
|
||
sanitized_tile_prompt.as_str()
|
||
};
|
||
|
||
format!(
|
||
"生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}。所有落点素材都必须保持统一的正面30度视角:相机位于物体正前方略高位置,镜头向下约30度,能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙瓣、西瓜块、草莓、菠萝块、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物。\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面。\n整体风格为清爽自然的休闲手游主题物体素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定:允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散;只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体,差异主要来自物体种类和原生轮廓,不使用固定形状脚本;相邻格可以形状相似,只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法。\n每个落点必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗、外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、彩色光晕、发光底边、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角,但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色,主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上。\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, no colored shadow or magenta fringe around objects, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons."
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
|
||
"文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界"
|
||
}
|
||
|
||
fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
|
||
let mut value = tile_prompt.trim().to_string();
|
||
if value.is_empty() {
|
||
return value;
|
||
}
|
||
value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
|
||
|
||
const REPLACEMENTS: [(&str, &str); 18] = [
|
||
("俯视角", "正面30度视角"),
|
||
("正上方视角", "正面30度视角"),
|
||
("鸟瞰视角", "正面30度视角"),
|
||
("平铺俯拍", "正面30度视角"),
|
||
("可落脚平台素材", "跳跃落点主题物体"),
|
||
("清爽游戏化立体感平台素材", "清爽游戏化立体感主题物体"),
|
||
("平台裸素材", "主题物体裸素材"),
|
||
("每格一个完整平台", "每格一个完整主题物体"),
|
||
("平台素材", "主题物体"),
|
||
("可落脚平台", "跳跃落点"),
|
||
("可落脚", "落点"),
|
||
("平台", "主题物体"),
|
||
("跳台", "落点"),
|
||
("地块", "主题物体"),
|
||
("地砖", "主题物体"),
|
||
("底座", "承托物"),
|
||
("底盘", "承托物"),
|
||
("地板", "承托物"),
|
||
];
|
||
|
||
for (from, to) in REPLACEMENTS {
|
||
value = value.replace(from, to);
|
||
}
|
||
while value.contains("正面30度视角正面30度视角") {
|
||
value = value.replace("正面30度视角正面30度视角", "正面30度视角");
|
||
}
|
||
|
||
value
|
||
}
|
||
|
||
fn slice_jump_hop_tile_atlas(
|
||
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("跳一跳地块图集解码失败:{error}"),
|
||
}))
|
||
})?;
|
||
let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen();
|
||
let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options);
|
||
let width = source.width();
|
||
let height = source.height();
|
||
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
if cell_width == 0 || cell_height == 0 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": "跳一跳地块图集尺寸过小,无法切割。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_COUNT);
|
||
for index in 0..JUMP_HOP_TILE_ITEM_COUNT {
|
||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let cropped = source.crop_imm(
|
||
x0,
|
||
y0,
|
||
x1.saturating_sub(x0).max(1),
|
||
y1.saturating_sub(y0).max(1),
|
||
);
|
||
let cleaned =
|
||
crop_generated_asset_sheet_view_edge_matte_with_options(cropped, alpha_options);
|
||
let cleaned = keep_jump_hop_largest_alpha_component(cleaned);
|
||
let cleaned =
|
||
crop_generated_asset_sheet_view_edge_matte_with_options(cleaned, alpha_options);
|
||
let cleaned = pad_jump_hop_tile_slice_image(cleaned);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
cleaned
|
||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("跳一跳地块图集切割失败:{error}"),
|
||
}))
|
||
})?;
|
||
slices.push(JumpHopTileAtlasSlice {
|
||
tile_type: jump_hop_tile_type_by_index(index),
|
||
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||
bytes: cursor.into_inner(),
|
||
});
|
||
}
|
||
|
||
Ok(slices)
|
||
}
|
||
|
||
fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage {
|
||
let source = image.to_rgba8();
|
||
let (width, height) = source.dimensions();
|
||
if width == 0 || height == 0 {
|
||
return image::DynamicImage::ImageRgba8(source);
|
||
}
|
||
|
||
// 中文注释:生图偶尔会让主体贴近单元格边缘;切片入库前补透明安全边,
|
||
// 避免运行态缩放或滤镜让主体看起来被裁掉。
|
||
let pad_x = (width / 12).clamp(8, 24);
|
||
let pad_y = (height / 12).clamp(8, 24);
|
||
let mut padded = image::RgbaImage::from_pixel(
|
||
width.saturating_add(pad_x.saturating_mul(2)),
|
||
height.saturating_add(pad_y.saturating_mul(2)),
|
||
image::Rgba([0, 0, 0, 0]),
|
||
);
|
||
image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into());
|
||
image::DynamicImage::ImageRgba8(padded)
|
||
}
|
||
|
||
fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage {
|
||
let mut source = image.to_rgba8();
|
||
let (width, height) = source.dimensions();
|
||
if width == 0 || height == 0 {
|
||
return image::DynamicImage::ImageRgba8(source);
|
||
}
|
||
|
||
// 中文注释:模型偶尔会让相邻格的叶片、果梗或阴影越界进当前格;
|
||
// 每格只保留最大的 alpha 连通主体,能去掉这些小碎片再入库。
|
||
let width_usize = width as usize;
|
||
let height_usize = height as usize;
|
||
let pixel_count = width_usize.saturating_mul(height_usize);
|
||
let mut visited = vec![false; pixel_count];
|
||
let mut best_component = Vec::<usize>::new();
|
||
|
||
for start in 0..pixel_count {
|
||
if visited[start] || source.as_raw()[start * 4 + 3] <= 16 {
|
||
visited[start] = true;
|
||
continue;
|
||
}
|
||
|
||
let mut queue = VecDeque::from([start]);
|
||
let mut component = Vec::<usize>::new();
|
||
visited[start] = true;
|
||
|
||
while let Some(index) = queue.pop_front() {
|
||
component.push(index);
|
||
let x = index % width_usize;
|
||
let y = index / width_usize;
|
||
|
||
for offset_y in -1i32..=1 {
|
||
for offset_x in -1i32..=1 {
|
||
if offset_x == 0 && offset_y == 0 {
|
||
continue;
|
||
}
|
||
let next_x = x as i32 + offset_x;
|
||
let next_y = y as i32 + offset_y;
|
||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
|
||
{
|
||
continue;
|
||
}
|
||
let next = next_y as usize * width_usize + next_x as usize;
|
||
if visited[next] {
|
||
continue;
|
||
}
|
||
visited[next] = true;
|
||
if source.as_raw()[next * 4 + 3] > 16 {
|
||
queue.push_back(next);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if component.len() > best_component.len() {
|
||
best_component = component;
|
||
}
|
||
}
|
||
|
||
if best_component.is_empty() {
|
||
return image::DynamicImage::ImageRgba8(source);
|
||
}
|
||
|
||
let mut keep = vec![false; pixel_count];
|
||
for index in best_component {
|
||
keep[index] = true;
|
||
}
|
||
for index in 0..pixel_count {
|
||
if keep[index] {
|
||
continue;
|
||
}
|
||
let pixel =
|
||
source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32);
|
||
pixel.0[3] = 0;
|
||
}
|
||
|
||
image::DynamicImage::ImageRgba8(source)
|
||
}
|
||
|
||
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||
match index {
|
||
0 => JumpHopTileType::Start,
|
||
value if value % 11 == 0 => JumpHopTileType::Bonus,
|
||
value if value % 7 == 0 => JumpHopTileType::Accent,
|
||
value if value % 3 == 0 => JumpHopTileType::Target,
|
||
_ => JumpHopTileType::Normal,
|
||
}
|
||
}
|
||
|
||
fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String {
|
||
format!("tile-{:02}", tile_index + 1)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn persist_jump_hop_tile_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
tile_index: usize,
|
||
tile_slice: JumpHopTileAtlasSlice,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopTileAsset, Response> {
|
||
let slot = jump_hop_tile_asset_slot_name(tile_index);
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: tile_slice.bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let persisted = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
slot.as_str(),
|
||
&format!(
|
||
"跳一跳地块切片 {}:{}",
|
||
tile_index + 1,
|
||
tile_slice.source_atlas_cell
|
||
),
|
||
image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
256,
|
||
192,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
Ok(JumpHopTileAsset {
|
||
tile_type: tile_slice.tile_type,
|
||
tile_id: Some(slot),
|
||
image_src: persisted.image_src,
|
||
image_object_key: persisted.image_object_key,
|
||
asset_object_id: persisted.asset_object_id,
|
||
source_atlas_cell: tile_slice.source_atlas_cell,
|
||
atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1),
|
||
atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1),
|
||
visual_width: 256,
|
||
visual_height: 192,
|
||
top_surface_radius: 42.0,
|
||
landing_radius: 34.0,
|
||
})
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
async fn persist_jump_hop_generated_image_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
slot: &str,
|
||
prompt: &str,
|
||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||
prefix: LegacyAssetPrefix,
|
||
width: u32,
|
||
height: u32,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopCharacterAsset, Response> {
|
||
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||
let prepared =
|
||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||
prefix,
|
||
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||
file_stem: "image".to_string(),
|
||
image: GeneratedImageAssetDataUrl {
|
||
format: image_format,
|
||
bytes: image.bytes,
|
||
},
|
||
access: OssObjectAccess::Private,
|
||
metadata: GeneratedImageAssetAdapterMetadata {
|
||
asset_kind: Some(format!("jump-hop-{slot}")),
|
||
owner_user_id: Some(owner_user_id.to_string()),
|
||
entity_kind: Some("jump_hop_work".to_string()),
|
||
entity_id: Some(profile_id.to_string()),
|
||
slot: Some(slot.to_string()),
|
||
provider: Some("vector-engine".to_string()),
|
||
task_id: None,
|
||
},
|
||
extra_metadata: BTreeMap::new(),
|
||
})
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "generated-image-assets",
|
||
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
|
||
})),
|
||
)
|
||
})?;
|
||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
})),
|
||
)
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let put_result = oss_client
|
||
.put_object(&http_client, prepared.request)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let head = oss_client
|
||
.head_object(
|
||
&http_client,
|
||
OssHeadObjectRequest {
|
||
object_key: put_result.object_key.clone(),
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let now_micros = current_utc_micros();
|
||
let asset_object_input = build_asset_object_upsert_input(
|
||
generate_asset_object_id(now_micros),
|
||
head.bucket,
|
||
head.object_key.clone(),
|
||
AssetObjectAccessPolicy::Private,
|
||
head.content_type.or(Some(persisted_mime_type)),
|
||
head.content_length,
|
||
head.etag,
|
||
format!("jump-hop-{slot}"),
|
||
None,
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-object",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let asset_object = state
|
||
.spacetime_client()
|
||
.confirm_asset_object(asset_object_input)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let binding_input = build_asset_entity_binding_input(
|
||
generate_asset_binding_id(now_micros),
|
||
asset_object.asset_object_id.clone(),
|
||
"jump_hop_work".to_string(),
|
||
profile_id.to_string(),
|
||
slot.to_string(),
|
||
format!("jump-hop-{slot}"),
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-entity-binding",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
state
|
||
.spacetime_client()
|
||
.bind_asset_object_to_entity(binding_input)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
Ok(JumpHopCharacterAsset {
|
||
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
|
||
image_src: put_result.legacy_public_path,
|
||
image_object_key: head.object_key,
|
||
asset_object_id: asset_object.asset_object_id,
|
||
generation_provider: "vector-engine".to_string(),
|
||
prompt: prompt.to_string(),
|
||
width,
|
||
height,
|
||
})
|
||
}
|
||
|
||
fn build_jump_hop_work_play_tracking_draft(
|
||
principal: &RuntimePrincipal,
|
||
work_id: impl Into<String>,
|
||
source_route: &'static str,
|
||
) -> WorkPlayTrackingDraft {
|
||
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_entry_display_name(
|
||
state: &AppState,
|
||
mut entry: JumpHopLeaderboardEntry,
|
||
) -> JumpHopLeaderboardEntry {
|
||
entry.display_name = resolve_jump_hop_leaderboard_display_name(state, &entry.player_id);
|
||
entry
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_display_name(state: &AppState, player_id: &str) -> String {
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup(player_id, |user_id| {
|
||
state
|
||
.auth_user_service()
|
||
.get_user_by_id(user_id)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|user| normalize_non_empty_text(user.display_name.as_str()))
|
||
})
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_display_name_with_lookup(
|
||
player_id: &str,
|
||
lookup_display_name: impl FnOnce(&str) -> Option<String>,
|
||
) -> String {
|
||
let player_id = player_id.trim();
|
||
if player_id.is_empty() {
|
||
return "玩家".to_string();
|
||
}
|
||
if player_id.starts_with("guest-runtime-") {
|
||
return "游客玩家".to_string();
|
||
}
|
||
|
||
lookup_display_name(player_id)
|
||
.and_then(|display_name| normalize_non_empty_text(display_name.as_str()))
|
||
.unwrap_or_else(|| "失效玩家".to_string())
|
||
}
|
||
|
||
fn normalize_non_empty_text(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
|
||
runtime_mode.trim().eq_ignore_ascii_case("draft")
|
||
}
|
||
|
||
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||
let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
|
||
JumpHopDraftResponse {
|
||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||
profile_id: None,
|
||
theme_text: theme_text.clone(),
|
||
work_title: clean_or_default(&payload.work_title, &theme_text),
|
||
work_description: clean_or_default(
|
||
&payload.work_description,
|
||
&format!("{theme_text}主题的俯视角跳跃作品"),
|
||
),
|
||
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
||
difficulty: payload.difficulty.clone(),
|
||
style_preset: payload.style_preset.clone(),
|
||
default_character: Some(default_jump_hop_character()),
|
||
character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"),
|
||
tile_prompt: clean_or_default(
|
||
&payload.tile_prompt,
|
||
&format!("{theme_text}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"),
|
||
),
|
||
end_mood_prompt: payload
|
||
.end_mood_prompt
|
||
.as_ref()
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty()),
|
||
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 validate_workspace_request(
|
||
request_context: &RequestContext,
|
||
payload: &JumpHopWorkspaceCreateRequest,
|
||
) -> Result<(), Response> {
|
||
ensure_non_empty(request_context, &payload.theme_text, "themeText")?;
|
||
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
|
||
return Err(jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": JUMP_HOP_PROVIDER,
|
||
"message": "templateId 必须为 jump-hop",
|
||
})),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_theme_text(theme_text: &str, fallback: &str) -> String {
|
||
clean_or_default(theme_text, fallback)
|
||
.chars()
|
||
.take(60)
|
||
.collect::<String>()
|
||
}
|
||
|
||
fn clean_or_default(value: &str, fallback: &str) -> String {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
fallback.trim().to_string()
|
||
} else {
|
||
value.to_string()
|
||
}
|
||
}
|
||
|
||
fn default_jump_hop_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(),
|
||
}
|
||
}
|
||
|
||
fn ensure_non_empty(
|
||
request_context: &RequestContext,
|
||
value: &str,
|
||
field: &str,
|
||
) -> Result<(), Response> {
|
||
if value.trim().is_empty() {
|
||
return Err(jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": JUMP_HOP_PROVIDER,
|
||
"field": field,
|
||
"message": format!("{field} 不能为空"),
|
||
})),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||
let mut normalized = Vec::new();
|
||
for tag in tags {
|
||
let tag = tag.trim();
|
||
if tag.is_empty() || normalized.iter().any(|item| item == tag) {
|
||
continue;
|
||
}
|
||
normalized.push(tag.to_string());
|
||
if normalized.len() >= 6 {
|
||
break;
|
||
}
|
||
}
|
||
normalized
|
||
}
|
||
|
||
fn jump_hop_json<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, Response> {
|
||
payload.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError {
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("不存在")
|
||
|| message.contains("not found")
|
||
|| message.contains("does not exist") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("发布需要")
|
||
|| message.contains("不能为空")
|
||
|| message.contains("必须") =>
|
||
{
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn jump_hop_error_response(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
error: AppError,
|
||
) -> Response {
|
||
let mut response = error.into_response_with_context(Some(request_context));
|
||
response.headers_mut().insert(
|
||
HeaderName::from_static("x-genarrative-provider"),
|
||
header::HeaderValue::from_str(provider)
|
||
.unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")),
|
||
);
|
||
response
|
||
}
|
||
|
||
fn current_utc_micros() -> i64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() {
|
||
assert!(is_jump_hop_draft_runtime_mode("draft"));
|
||
assert!(is_jump_hop_draft_runtime_mode(" DRAFT "));
|
||
assert!(!is_jump_hop_draft_runtime_mode("published"));
|
||
assert!(!is_jump_hop_draft_runtime_mode(""));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_leaderboard_display_name_never_falls_back_to_player_id() {
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup(" user-secret-1 ", |user_id| {
|
||
assert_eq!(user_id, "user-secret-1");
|
||
Some(" 陶泥儿玩家 ".to_string())
|
||
}),
|
||
"陶泥儿玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("guest-runtime-1", |_| {
|
||
panic!("guest player should not query account display name")
|
||
}),
|
||
"游客玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("user-missing", |_| None),
|
||
"失效玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("", |_| {
|
||
panic!("empty player id should not query account display name")
|
||
}),
|
||
"玩家"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
||
|
||
assert!(prompt.contains("五行五列"));
|
||
assert!(prompt.contains("25个独立"));
|
||
assert!(prompt.contains("跳跃落点主题物体"));
|
||
assert!(prompt.contains("不要画成游戏界面"));
|
||
assert!(prompt.contains("视觉方向为正面30度视角"));
|
||
assert!(prompt.contains("所有落点素材都必须保持统一的正面30度视角"));
|
||
assert!(prompt.contains("相机位于物体正前方略高位置"));
|
||
assert!(prompt.contains("镜头向下约30度"));
|
||
assert!(prompt.contains("能看到清晰正面、侧壁、下沿和少量上表面"));
|
||
assert!(prompt.contains("主体正面或侧壁可见面积必须接近或大于顶面面积"));
|
||
assert!(prompt.contains("顶面只能作为辅助可见面"));
|
||
assert!(prompt.contains("不要让顶面占据主要视觉"));
|
||
assert!(prompt.contains("不要画成纯俯视、正上方俯拍、鸟瞰地图块"));
|
||
assert!(prompt.contains("水果主题尤其要避免俯拍"));
|
||
assert!(prompt.contains("橙瓣必须看到橙皮正面外侧和果肉厚度"));
|
||
assert!(prompt.contains("浆果不能只是一个从上往下看的圆形球顶"));
|
||
assert!(prompt.contains("主题要一眼可见"));
|
||
assert!(prompt.contains("每个落点都是符合主题且有设计感的立体感物体"));
|
||
assert!(prompt.contains("清爽自然的休闲手游主题物体素材"));
|
||
assert!(prompt.contains("符合主题且有设计感的立体感物体"));
|
||
assert!(prompt.contains("每一个落点都必须直接使用主题物体或合理发散物体"));
|
||
assert!(prompt.contains("苹果可近似圆"));
|
||
assert!(prompt.contains("香蕉可近似长条或长方形"));
|
||
assert!(prompt.contains("主题物体本身就是唯一可落脚体"));
|
||
assert!(prompt.contains("雪花落点就是一枚带厚度的雪花"));
|
||
assert!(prompt.contains("不要在主题物体下面再垫任何石头、土块、木板"));
|
||
assert!(prompt.contains("造型规则完全由物体本身决定"));
|
||
assert!(prompt.contains("允许圆形、长条、弧形、三角、扇形、块状"));
|
||
assert!(prompt.contains("只在同一2D/2.5D手绘风格"));
|
||
assert!(prompt.contains("同一正面30度视角"));
|
||
assert!(prompt.contains("不使用固定形状脚本"));
|
||
assert!(prompt.contains("允许用主题物体自身的切面、边缘厚度"));
|
||
assert!(prompt.contains("禁止额外支撑层、承托底座、脚下地板"));
|
||
assert!(prompt.contains("四周至少保留18%纯洋红安全留白"));
|
||
assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX));
|
||
assert!(prompt.contains("主体允许使用绿色、白色、雪地、云朵、草地和花朵"));
|
||
assert!(prompt.contains("不绘制落地投影"));
|
||
assert!(prompt.contains("不绘制落地投影、接触阴影、方形阴影、洋红阴影"));
|
||
assert!(prompt.contains("紫色底边、彩色光晕、发光底边"));
|
||
assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格"));
|
||
assert!(prompt.contains("主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影"));
|
||
assert!(prompt.contains("English guardrail"));
|
||
assert!(prompt.contains("front-facing 30-degree camera-pitch"));
|
||
assert!(prompt.contains("camera slightly above the object"));
|
||
assert!(
|
||
prompt.contains("visible front/side area must be close to or larger than the top area")
|
||
);
|
||
assert!(prompt.contains("never produce top-down"));
|
||
assert!(prompt.contains("each object's native silhouette decides the shape"));
|
||
assert!(prompt.contains("no extra base under the object"));
|
||
assert!(prompt.contains("no pedestal"));
|
||
assert!(prompt.contains("no floor slab"));
|
||
assert!(prompt.contains("no colored shadow or magenta fringe around objects"));
|
||
assert!(!prompt.contains("可落脚平台素材"));
|
||
assert!(!prompt.contains("平台裸素材"));
|
||
assert!(!prompt.contains("每格一个完整平台"));
|
||
assert!(!prompt.contains("25个平台"));
|
||
assert!(!prompt.contains("platform, each"));
|
||
assert!(!prompt.contains("only platform"));
|
||
assert!(!prompt.contains("基础轮廓优先做不规则主题剪影"));
|
||
assert!(!prompt.contains("25格造型要混排"));
|
||
assert!(!prompt.contains("no simple circles"));
|
||
assert!(!prompt.contains("no simple squares"));
|
||
assert!(!prompt.contains("纯绿色绿幕"));
|
||
assert!(!prompt.contains("#00FF00"));
|
||
assert!(!prompt.contains("isolated top-down"));
|
||
assert!(!prompt.contains("按5行*5列"));
|
||
assert!(!prompt.contains("2D地板图标"));
|
||
assert!(!prompt.contains("清爽自然的游戏图标"));
|
||
assert!(!prompt.contains("边缘厚度暗示"));
|
||
assert!(!prompt.contains("统一投影"));
|
||
assert!(!prompt.contains("每个物品生成"));
|
||
assert!(!prompt.contains("不同视图"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_background_prompt_keeps_center_corridor_and_side_atmosphere() {
|
||
let prompt = build_jump_hop_background_prompt("水果");
|
||
|
||
assert!(prompt.contains("9:16竖版跳一跳游戏背景底图"));
|
||
assert!(prompt.contains("主题关键词严格只使用“水果”"));
|
||
assert!(prompt.contains("整体风格需要和同一主题的跳一跳游戏元素一致"));
|
||
assert!(prompt.contains("左右两侧氛围为主"));
|
||
assert!(prompt.contains("中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊"));
|
||
assert!(prompt.contains("该区域只能使用少量低对比度纹理"));
|
||
assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
|
||
assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
|
||
assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
|
||
assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标"));
|
||
assert!(prompt.contains("运行态会叠加独立可点击按钮资产"));
|
||
assert!(prompt.contains("视角保持正面约30度"));
|
||
assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
|
||
assert!(prompt.contains("English guardrail"));
|
||
assert!(prompt.contains("left and right sides carry the atmosphere"));
|
||
assert!(prompt.contains("central vertical half-width corridor stays simple"));
|
||
assert!(prompt.contains("no top-left back button"));
|
||
assert!(prompt.contains("no platforms"));
|
||
assert!(prompt.contains("no landing objects"));
|
||
assert!(prompt.contains("no other UI panels"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() {
|
||
let prompt = build_jump_hop_back_button_prompt("水果");
|
||
|
||
assert!(prompt.contains("独立透明素材"));
|
||
assert!(prompt.contains("主题关键词严格只使用“水果”"));
|
||
assert!(prompt.contains("单个标准圆形图标"));
|
||
assert!(prompt.contains("内部只有一个居中的向左箭头"));
|
||
assert!(prompt.contains("不要写“返回”文字"));
|
||
assert!(prompt.contains("背景必须是单一纯绿色 #00FF00"));
|
||
assert!(prompt.contains("后续由服务端扣除绿色背景"));
|
||
assert!(prompt.contains("one standalone circular mobile game back button asset only"));
|
||
assert!(prompt.contains("solid #00FF00 green-screen background"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_background_negative_prompt_blocks_runtime_layer_conflicts() {
|
||
let negative_prompt = build_jump_hop_background_negative_prompt();
|
||
|
||
assert!(negative_prompt.contains("跳板"));
|
||
assert!(negative_prompt.contains("地块"));
|
||
assert!(negative_prompt.contains("落脚物"));
|
||
assert!(negative_prompt.contains("角色"));
|
||
assert!(negative_prompt.contains("UI按钮"));
|
||
assert!(negative_prompt.contains("返回按钮"));
|
||
assert!(negative_prompt.contains("左上角图标"));
|
||
assert!(negative_prompt.contains("右上角按钮"));
|
||
assert!(negative_prompt.contains("底部按钮"));
|
||
assert!(negative_prompt.contains("UI面板"));
|
||
assert!(negative_prompt.contains("中央堆满元素"));
|
||
assert!(negative_prompt.contains("中央遮挡"));
|
||
assert!(negative_prompt.contains("纯俯视地图"));
|
||
assert!(negative_prompt.contains("平铺俯拍"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_legacy_cover_placeholder_is_not_treated_as_background() {
|
||
assert!(is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png",
|
||
));
|
||
assert!(is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/generated-jump-hop-assets/jump-hop-profile-test/cover-composite-123.png",
|
||
));
|
||
assert!(!is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/generated-jump-hop-assets/jump-hop-profile-test/background/image.png",
|
||
));
|
||
assert!(!is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/uploads/custom-cover.png",
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() {
|
||
let background_prompt = build_jump_hop_background_prompt("宝可梦");
|
||
assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
|
||
assert!(!background_prompt.contains("宝可梦"));
|
||
|
||
let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon");
|
||
assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
|
||
assert!(!back_button_prompt.contains("Pokemon"));
|
||
|
||
let tile_prompt = build_jump_hop_tile_atlas_prompt(
|
||
"宝可梦",
|
||
"宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰",
|
||
);
|
||
assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”"));
|
||
assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题"));
|
||
assert!(tile_prompt.contains("黄色闪电萌宠符号"));
|
||
assert!(tile_prompt.contains("彩色冒险能量球"));
|
||
assert!(!tile_prompt.contains("宝可梦"));
|
||
assert!(!tile_prompt.contains("皮卡丘"));
|
||
assert!(!tile_prompt.contains("精灵球"));
|
||
|
||
let normal_prompt =
|
||
build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集");
|
||
assert!(normal_prompt.contains("主题为“水果”"));
|
||
assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt(
|
||
"科幻芯片",
|
||
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
|
||
);
|
||
|
||
assert!(prompt.contains("画面内容是科幻芯片主题的正面30度视角清爽游戏化立体感主题物体"));
|
||
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
|
||
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
|
||
|
||
let top_down_prompt =
|
||
build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台");
|
||
|
||
assert!(top_down_prompt.contains("画面内容是水果主题正面30度视角圆形主题物体"));
|
||
assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角"));
|
||
assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍"));
|
||
|
||
let legacy_prompt = build_jump_hop_tile_atlas_prompt(
|
||
"雪花",
|
||
"雪花主题可落脚平台素材,每格一个完整平台,不要底座",
|
||
);
|
||
|
||
assert!(legacy_prompt.contains("雪花主题跳跃落点主题物体"));
|
||
assert!(legacy_prompt.contains("每格一个完整主题物体"));
|
||
assert!(legacy_prompt.contains("不要承托物"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚平台素材"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题平台"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题地块"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() {
|
||
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
|
||
|
||
assert!(negative_prompt.contains("油亮高光"));
|
||
assert!(negative_prompt.contains("厚重CG渲染"));
|
||
assert!(negative_prompt.contains("游戏界面"));
|
||
assert!(negative_prompt.contains("图标集页面"));
|
||
assert!(negative_prompt.contains("纯俯视角"));
|
||
assert!(negative_prompt.contains("正上方视角"));
|
||
assert!(negative_prompt.contains("鸟瞰视角"));
|
||
assert!(negative_prompt.contains("顶面占主画面"));
|
||
assert!(negative_prompt.contains("只看顶面"));
|
||
assert!(negative_prompt.contains("圆形顶视图"));
|
||
assert!(negative_prompt.contains("扁平图标"));
|
||
assert!(negative_prompt.contains("方形阴影"));
|
||
assert!(negative_prompt.contains("洋红阴影"));
|
||
assert!(negative_prompt.contains("紫色底边"));
|
||
assert!(negative_prompt.contains("粉色脏边"));
|
||
assert!(negative_prompt.contains("洋红色描边"));
|
||
assert!(negative_prompt.contains("彩色光晕"));
|
||
assert!(negative_prompt.contains("发光底边"));
|
||
assert!(negative_prompt.contains("方形底板"));
|
||
assert!(negative_prompt.contains("额外底座"));
|
||
assert!(negative_prompt.contains("承托底座"));
|
||
assert!(negative_prompt.contains("台座"));
|
||
assert!(negative_prompt.contains("物体摆在平台上"));
|
||
assert!(negative_prompt.contains("物体下方垫地板"));
|
||
assert!(!negative_prompt.contains("规则圆盘"));
|
||
assert!(!negative_prompt.contains("正圆平台"));
|
||
assert!(!negative_prompt.contains("规则方块"));
|
||
assert!(!negative_prompt.contains("圆角矩形"));
|
||
assert!(!negative_prompt.contains("杯垫"));
|
||
assert!(!negative_prompt.contains("重复圆形"));
|
||
assert!(!negative_prompt.contains("建筑"));
|
||
assert!(!negative_prompt.contains("楼房"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_slice_keeps_largest_alpha_component() {
|
||
let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0]));
|
||
for y in 12..52 {
|
||
for x in 12..52 {
|
||
image.put_pixel(x, y, image::Rgba([220, 70, 50, 255]));
|
||
}
|
||
}
|
||
for y in 68..74 {
|
||
for x in 36..42 {
|
||
image.put_pixel(x, y, image::Rgba([40, 190, 80, 255]));
|
||
}
|
||
}
|
||
|
||
let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image))
|
||
.to_rgba8();
|
||
|
||
assert_eq!(cleaned.get_pixel(20, 20).0[3], 255);
|
||
assert_eq!(
|
||
cleaned.get_pixel(38, 70).0[3],
|
||
0,
|
||
"相邻格侵入的小碎片不应扩大当前地块切片边界"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() {
|
||
let width = 500;
|
||
let height = 500;
|
||
let mut atlas = image::RgbaImage::new(width, height);
|
||
for row in 0..5 {
|
||
for col in 0..5 {
|
||
let index = row * 5 + col;
|
||
let color = image::Rgba([
|
||
40 + index as u8 * 3,
|
||
24 + index as u8 * 5,
|
||
120 + index as u8 * 2,
|
||
255,
|
||
]);
|
||
for y in row as u32 * 100..(row as u32 + 1) * 100 {
|
||
for x in col as u32 * 100..(col as u32 + 1) * 100 {
|
||
atlas.put_pixel(x, y, color);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(atlas)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.expect("atlas should encode");
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: encoded.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
|
||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||
assert_eq!(
|
||
slice.source_atlas_cell,
|
||
format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1)
|
||
);
|
||
let decoded = image::load_from_memory(slice.bytes.as_slice())
|
||
.expect("tile slice should decode")
|
||
.to_rgba8();
|
||
assert_eq!(
|
||
decoded.dimensions(),
|
||
(116, 116),
|
||
"跳一跳地块切片应在 100x100 单元格外补透明安全边"
|
||
);
|
||
let color = [
|
||
40 + index as u8 * 3,
|
||
24 + index as u8 * 5,
|
||
120 + index as u8 * 2,
|
||
255,
|
||
];
|
||
assert!(
|
||
decoded.pixels().any(|pixel| pixel.0 == color),
|
||
"第 {index} 个地块切片应保留对应格子的主体颜色"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() {
|
||
let width = 500;
|
||
let height = 500;
|
||
let mut atlas =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
|
||
for row in 0..5 {
|
||
for col in 0..5 {
|
||
let color = if row == 0 && col == 0 {
|
||
image::Rgba([62, 188, 74, 255])
|
||
} else if row == 0 && col == 1 {
|
||
image::Rgba([246, 246, 238, 255])
|
||
} else {
|
||
image::Rgba([120, 96, 72, 255])
|
||
};
|
||
let center_x = col as u32 * 100 + 50;
|
||
let center_y = row as u32 * 100 + 50;
|
||
for y in center_y - 24..center_y + 24 {
|
||
for x in center_x - 28..center_x + 28 {
|
||
atlas.put_pixel(x, y, color);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(atlas)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.expect("atlas should encode");
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: encoded.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
let green_tile = image::load_from_memory(slices[0].bytes.as_slice())
|
||
.expect("green tile should decode")
|
||
.to_rgba8();
|
||
let white_tile = image::load_from_memory(slices[1].bytes.as_slice())
|
||
.expect("white tile should decode")
|
||
.to_rgba8();
|
||
|
||
assert!(
|
||
green_tile
|
||
.pixels()
|
||
.any(|pixel| pixel.0 == [62, 188, 74, 255])
|
||
);
|
||
assert!(
|
||
white_tile
|
||
.pixels()
|
||
.any(|pixel| pixel.0 == [246, 246, 238, 255])
|
||
);
|
||
assert_eq!(green_tile.get_pixel(0, 0).0[3], 0);
|
||
assert_eq!(white_tile.get_pixel(0, 0).0[3], 0);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() {
|
||
let slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
|
||
.map(jump_hop_tile_asset_slot_name)
|
||
.collect::<Vec<_>>();
|
||
let unique_slots = slots
|
||
.iter()
|
||
.cloned()
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
|
||
assert_eq!(
|
||
unique_slots.len(),
|
||
JUMP_HOP_TILE_ITEM_COUNT,
|
||
"25 个地块切片必须写入 25 个独立 slot/path,不能按重复的 tile_type 互相覆盖"
|
||
);
|
||
}
|
||
}
|