feat(jump-hop): redesign sling platform gameplay
This commit is contained in:
@@ -94,13 +94,11 @@ pub async fn generate_character_visual(
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
let settings = require_openai_image_settings(&state)?
|
||||
.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.clone()),
|
||||
Some(character_id.clone()),
|
||||
)
|
||||
;
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.clone()),
|
||||
Some(character_id.clone()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
|
||||
state
|
||||
@@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
|
||||
&model,
|
||||
&prompt,
|
||||
)?;
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(character_id.clone()),
|
||||
);
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone()));
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
|
||||
@@ -255,6 +255,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();
|
||||
|
||||
@@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image(
|
||||
"scene_image",
|
||||
asset_id.as_str(),
|
||||
async {
|
||||
let settings = require_openai_image_settings(&state)?
|
||||
.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
normalized.profile_id.clone(),
|
||||
);
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
normalized.profile_id.clone(),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image =
|
||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||
|
||||
@@ -13,15 +13,15 @@ use serde_json::{Value, json};
|
||||
use shared_contracts::jump_hop::{
|
||||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||||
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
|
||||
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
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,
|
||||
collections::{BTreeMap, VecDeque},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -46,8 +46,7 @@ use crate::{
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] =
|
||||
["start", "normal", "target", "finish", "bonus", "accent"];
|
||||
const JUMP_HOP_TILE_ITEM_COUNT: usize = 25;
|
||||
|
||||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||||
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||||
@@ -55,8 +54,8 @@ 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 = 2;
|
||||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
||||
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5;
|
||||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct JumpHopTileAtlasSlice {
|
||||
@@ -239,6 +238,35 @@ pub async fn get_jump_hop_runtime_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,
|
||||
viewer_best: leaderboard.viewer_best,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn start_jump_hop_run(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -247,6 +275,7 @@ pub async fn start_jump_hop_run(
|
||||
) -> 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() == Some("draft");
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let principal_kind = principal.kind().as_str();
|
||||
let run = state
|
||||
@@ -261,23 +290,25 @@ pub async fn start_jump_hop_run(
|
||||
)
|
||||
})?;
|
||||
|
||||
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,
|
||||
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,
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -391,15 +422,17 @@ async fn maybe_generate_jump_hop_assets(
|
||||
owner_user_id: &str,
|
||||
payload: &mut JumpHopActionRequest,
|
||||
) -> Result<(), Response> {
|
||||
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
|
||||
if !matches!(
|
||||
payload.action_type,
|
||||
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
if payload.character_asset.is_some()
|
||||
&& payload.tile_atlas_asset.is_some()
|
||||
if payload.tile_atlas_asset.is_some()
|
||||
&& payload
|
||||
.tile_assets
|
||||
.as_ref()
|
||||
.is_some_and(|assets| !assets.is_empty())
|
||||
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -414,12 +447,11 @@ async fn maybe_generate_jump_hop_assets(
|
||||
|
||||
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()),
|
||||
)
|
||||
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)
|
||||
@@ -428,58 +460,19 @@ async fn maybe_generate_jump_hop_assets(
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
|
||||
let character_prompt = payload
|
||||
.character_prompt
|
||||
let theme_text = payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.unwrap_or("俯视角可爱主角,透明背景");
|
||||
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集");
|
||||
.or(payload.work_title.as_deref())
|
||||
.unwrap_or("跳一跳");
|
||||
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text);
|
||||
|
||||
let character_generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
character_prompt,
|
||||
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
||||
"1024*1024",
|
||||
1,
|
||||
&[],
|
||||
"跳一跳角色资产生成失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
let character_image = character_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 character_asset = persist_jump_hop_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
profile_id.as_str(),
|
||||
"character",
|
||||
character_prompt,
|
||||
character_image,
|
||||
LegacyAssetPrefix::JumpHopAssets,
|
||||
768,
|
||||
768,
|
||||
request_context,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
|
||||
let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
|
||||
let tile_generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
sheet_prompt.as_str(),
|
||||
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
||||
Some(build_jump_hop_tile_atlas_negative_prompt()),
|
||||
"1024*1024",
|
||||
1,
|
||||
&[],
|
||||
@@ -527,7 +520,12 @@ async fn maybe_generate_jump_hop_assets(
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
payload.character_asset = Some(character_asset);
|
||||
if payload.character_asset.is_none() {
|
||||
payload.character_asset = Some(build_jump_hop_default_character_asset(
|
||||
profile_id.as_str(),
|
||||
theme_text,
|
||||
));
|
||||
}
|
||||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||||
payload.tile_assets = Some(tile_assets);
|
||||
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
|
||||
@@ -538,28 +536,29 @@ async fn maybe_generate_jump_hop_assets(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String {
|
||||
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
|
||||
let theme_text = theme_text.trim();
|
||||
let theme_text = if theme_text.is_empty() {
|
||||
"跳一跳"
|
||||
} else {
|
||||
theme_text
|
||||
};
|
||||
let subject_text = tile_prompt.trim();
|
||||
let subject_text = if subject_text.is_empty() {
|
||||
"等距立体地块图集"
|
||||
theme_text
|
||||
} else {
|
||||
subject_text
|
||||
};
|
||||
let cell_plan = [
|
||||
"第1行第1列:start 起点地块",
|
||||
"第1行第2列:normal 普通地块",
|
||||
"第1行第3列:target 目标地块",
|
||||
"第2行第1列:finish 终点地块",
|
||||
"第2行第2列:bonus 奖励地块",
|
||||
"第2行第3列:accent 视觉强调地块",
|
||||
]
|
||||
.join(";");
|
||||
|
||||
format!(
|
||||
"生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。"
|
||||
"生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳一跳可落脚平台素材,按五行五列均匀摆放在纯绿色绿幕画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为俯视角平台跳跃游戏,画面内容是{subject_text}。\n每一块平台都必须直接使用主题元素做主体造型,主题要一眼可见;例如主题为水果时,应是苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台,不得变成石板、金属按钮、徽章或装备。\n只画平台裸素材,不画外层面板、棋盘底座、菜单、按钮、标题、文字、角标、装饰边框、工具栏、装备、武器、徽章、道具或角色。\n整体风格为清爽自然的休闲手游平台素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每格一个完整平台,是符合主题且有设计感的立体感平台,有顶面和清晰轮廓;不要默认生成灰色石板或金属地砖,除非主题本身就是石头或金属。\n每格主体必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯绿色绿幕安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个平台只保留主体内部明暗和外轮廓,不绘制落地投影、接触阴影、方形阴影、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个平台同一材质体系、同一光向,但形状和细节有变化;每个平台之间只能是纯绿色空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是接近 #00FF00 的纯绿色绿幕,背景平整无纹理、无渐变、无阴影、无黑底;主体自身不得使用接近 #00FF00 的纯绿。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板或道具界面。\nEnglish guardrail: isolated top-down fruit-shaped jump pad assets only, green screen background, no text, no poster, no architecture, no building, no UI screen, no inventory icons."
|
||||
)
|
||||
}
|
||||
|
||||
fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
|
||||
"文字、Logo、水印、按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、灰色石板、金属地砖、建筑、楼房、海报、装备、武器、徽章、道具图标、UI图标卡、标题、说明文字、装饰边框、落地投影、接触阴影、方形阴影、方形底板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界"
|
||||
}
|
||||
|
||||
fn slice_jump_hop_tile_atlas(
|
||||
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||||
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||||
@@ -583,8 +582,8 @@ fn slice_jump_hop_tile_atlas(
|
||||
);
|
||||
}
|
||||
|
||||
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len());
|
||||
for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() {
|
||||
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;
|
||||
@@ -598,6 +597,9 @@ fn slice_jump_hop_tile_atlas(
|
||||
y1.saturating_sub(y0).max(1),
|
||||
);
|
||||
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
|
||||
let cleaned = keep_jump_hop_largest_alpha_component(cleaned);
|
||||
let cleaned = crop_generated_asset_sheet_view_edge_matte(cleaned);
|
||||
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)
|
||||
@@ -617,26 +619,116 @@ fn slice_jump_hop_tile_atlas(
|
||||
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,
|
||||
1 => JumpHopTileType::Normal,
|
||||
2 => JumpHopTileType::Target,
|
||||
3 => JumpHopTileType::Finish,
|
||||
4 => JumpHopTileType::Bonus,
|
||||
_ => JumpHopTileType::Accent,
|
||||
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_type: &JumpHopTileType) -> &'static str {
|
||||
match tile_type {
|
||||
JumpHopTileType::Start => "tile-start",
|
||||
JumpHopTileType::Normal => "tile-normal",
|
||||
JumpHopTileType::Target => "tile-target",
|
||||
JumpHopTileType::Finish => "tile-finish",
|
||||
JumpHopTileType::Bonus => "tile-bonus",
|
||||
JumpHopTileType::Accent => "tile-accent",
|
||||
}
|
||||
fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String {
|
||||
format!("tile-{:02}", tile_index + 1)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -648,7 +740,7 @@ async fn persist_jump_hop_tile_asset(
|
||||
tile_slice: JumpHopTileAtlasSlice,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<JumpHopTileAsset, Response> {
|
||||
let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type);
|
||||
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(),
|
||||
@@ -658,7 +750,7 @@ async fn persist_jump_hop_tile_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
slot,
|
||||
slot.as_str(),
|
||||
&format!(
|
||||
"跳一跳地块切片 {}:{}",
|
||||
tile_index + 1,
|
||||
@@ -674,10 +766,13 @@ async fn persist_jump_hop_tile_asset(
|
||||
|
||||
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,
|
||||
@@ -685,6 +780,22 @@ async fn persist_jump_hop_tile_asset(
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -868,17 +979,26 @@ fn build_jump_hop_work_play_tracking_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,
|
||||
work_title: payload.work_title.trim().to_string(),
|
||||
work_description: payload.work_description.trim().to_string(),
|
||||
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(),
|
||||
character_prompt: payload.character_prompt.trim().to_string(),
|
||||
tile_prompt: payload.tile_prompt.trim().to_string(),
|
||||
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}主题的俯视角清爽游戏化立体感平台素材"),
|
||||
),
|
||||
end_mood_prompt: payload
|
||||
.end_mood_prompt
|
||||
.as_ref()
|
||||
@@ -897,13 +1017,7 @@ fn validate_workspace_request(
|
||||
request_context: &RequestContext,
|
||||
payload: &JumpHopWorkspaceCreateRequest,
|
||||
) -> Result<(), Response> {
|
||||
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
&payload.character_prompt,
|
||||
"characterPrompt",
|
||||
)?;
|
||||
ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
|
||||
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,
|
||||
@@ -917,6 +1031,32 @@ fn validate_workspace_request(
|
||||
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,
|
||||
@@ -1020,32 +1160,82 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
|
||||
let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
|
||||
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
|
||||
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
||||
|
||||
assert!(prompt.contains("2行*3列"));
|
||||
assert!(prompt.contains("第1行第1列:start 起点地块"));
|
||||
assert!(prompt.contains("第2行第3列:accent 视觉强调地块"));
|
||||
assert!(prompt.contains("五行五列"));
|
||||
assert!(prompt.contains("共25个"));
|
||||
assert!(prompt.contains("可落脚平台素材"));
|
||||
assert!(prompt.contains("不要画成游戏界面"));
|
||||
assert!(prompt.contains("主题要一眼可见"));
|
||||
assert!(prompt.contains("每格一个完整平台"));
|
||||
assert!(prompt.contains("清爽自然的休闲手游平台素材"));
|
||||
assert!(prompt.contains("符合主题且有设计感的立体感平台"));
|
||||
assert!(prompt.contains("四周至少保留18%纯绿色绿幕安全留白"));
|
||||
assert!(prompt.contains("不绘制落地投影"));
|
||||
assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格"));
|
||||
assert!(prompt.contains("English guardrail"));
|
||||
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_tile_atlas_slices_one_png_per_tile_type() {
|
||||
let width = 300;
|
||||
let height = 200;
|
||||
let colors = [
|
||||
[220, 24, 24, 255],
|
||||
[240, 150, 32, 255],
|
||||
[248, 220, 72, 255],
|
||||
[52, 168, 84, 255],
|
||||
[38, 132, 255, 255],
|
||||
[156, 92, 220, 255],
|
||||
];
|
||||
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("方形底板"));
|
||||
}
|
||||
|
||||
#[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..2 {
|
||||
for col in 0..3 {
|
||||
let color = image::Rgba(colors[row * 3 + col]);
|
||||
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);
|
||||
@@ -1065,20 +1255,48 @@ mod tests {
|
||||
|
||||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||||
|
||||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len());
|
||||
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 / 3 + 1, index % 3 + 1)
|
||||
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 == colors[index]),
|
||||
decoded.pixels().any(|pixel| pixel.0 == color),
|
||||
"第 {index} 个地块切片应保留对应格子的主体颜色"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 互相覆盖"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene(
|
||||
config: &Match3DConfigJson,
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_item_spritesheet_prompt();
|
||||
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
||||
|
||||
@@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset(
|
||||
reference_image_srcs: Vec<String>,
|
||||
) -> Result<Match3DAssetUpload, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
|
||||
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
|
||||
@@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle(
|
||||
prompt: &str,
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
|
||||
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
||||
@@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image(
|
||||
prompt: &str,
|
||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||
require_match3d_oss_client(state)?;
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image = load_match3d_container_reference_image()?;
|
||||
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
||||
|
||||
@@ -7,8 +7,9 @@ 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,
|
||||
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,
|
||||
};
|
||||
@@ -54,6 +55,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(
|
||||
|
||||
@@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
||||
level_name: &str,
|
||||
puzzle_image: &PuzzleDownloadedImage,
|
||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||
let settings = require_puzzle_vector_engine_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(session_id.to_string()),
|
||||
);
|
||||
let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(session_id.to_string()),
|
||||
);
|
||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||
let scene_generated = create_puzzle_vector_engine_image_generation(
|
||||
|
||||
@@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings {
|
||||
) -> Self {
|
||||
self.external_api_audit_user_id = user_id;
|
||||
self.external_api_audit_profile_id = profile_id;
|
||||
self.external_api_audit_request_id =
|
||||
Some(request_context.request_id().to_string());
|
||||
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
|
||||
self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||||
|
||||
@@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url(
|
||||
size: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let settings = require_openai_image_settings(state)?
|
||||
.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,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,
|
||||
|
||||
@@ -293,6 +293,29 @@ mod tests {
|
||||
assert_eq!(wooden_fish.image_src, "/wooden-fish/default-hit-object.png");
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
@@ -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>,
|
||||
@@ -127,14 +136,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 +218,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)]
|
||||
@@ -251,6 +279,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,6 +303,8 @@ 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>,
|
||||
@@ -305,6 +336,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 +375,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 +397,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 +432,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 +460,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()],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -229,7 +229,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)
|
||||
}
|
||||
|
||||
@@ -242,13 +242,15 @@ impl SpacetimeClient {
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref());
|
||||
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 +305,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 +400,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 +442,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 +467,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 +532,6 @@ enum JumpHopActionProcedure {
|
||||
#[derive(Clone, Copy)]
|
||||
enum JumpHopDraftMergeScope {
|
||||
CompileDraft,
|
||||
RegenerateCharacter,
|
||||
RegenerateTiles,
|
||||
UpdateWorkMeta,
|
||||
UpdateDifficulty,
|
||||
@@ -484,7 +540,6 @@ enum JumpHopDraftMergeScope {
|
||||
#[derive(Clone, Copy)]
|
||||
enum JumpHopAssetRefresh {
|
||||
Preserve,
|
||||
Character,
|
||||
Tiles,
|
||||
}
|
||||
|
||||
@@ -496,12 +551,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 +575,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 +614,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 +648,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 +677,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);
|
||||
}
|
||||
@@ -665,28 +717,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 +748,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()),
|
||||
@@ -785,13 +828,15 @@ 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: "跳一跳主题的俯视角清爽游戏化立体感平台素材".to_string(),
|
||||
end_mood_prompt: None,
|
||||
character_asset: None,
|
||||
tile_atlas_asset: None,
|
||||
@@ -804,7 +849,7 @@ fn default_draft() -> JumpHopDraftResponse {
|
||||
|
||||
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 +859,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 +883,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 +930,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 +951,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 +971,7 @@ mod tests {
|
||||
.character_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("-character")
|
||||
.contains("builtin-three")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
@@ -1001,59 +985,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 +1011,7 @@ mod tests {
|
||||
.character_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("old-character")
|
||||
.contains("builtin-three")
|
||||
);
|
||||
assert!(
|
||||
!input
|
||||
@@ -1081,24 +1025,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,20 +1106,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")
|
||||
);
|
||||
}
|
||||
|
||||
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
|
||||
JumpHopActionRequest {
|
||||
action_type,
|
||||
profile_id: None,
|
||||
theme_text: None,
|
||||
work_title: None,
|
||||
work_description: None,
|
||||
theme_tags: None,
|
||||
@@ -1165,6 +1130,10 @@ mod tests {
|
||||
character_prompt: None,
|
||||
tile_prompt: None,
|
||||
end_mood_prompt: None,
|
||||
character_asset: None,
|
||||
tile_atlas_asset: None,
|
||||
tile_assets: None,
|
||||
cover_composite: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,9 +1148,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()
|
||||
}
|
||||
}
|
||||
@@ -1189,37 +1160,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,
|
||||
@@ -1227,16 +1170,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,
|
||||
|
||||
@@ -171,8 +171,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,
|
||||
|
||||
@@ -163,6 +163,7 @@ mod tests {
|
||||
let row = BarkBattleGalleryViewRow {
|
||||
work_id: "BB-33333333".to_string(),
|
||||
owner_user_id: "user-3".to_string(),
|
||||
author_display_name: "声浪玩家".to_string(),
|
||||
source_draft_id: Some("bark-battle-draft-3".to_string()),
|
||||
config_version: 1,
|
||||
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
||||
|
||||
@@ -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,6 +62,25 @@ 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 {
|
||||
@@ -70,6 +90,7 @@ pub(crate) fn map_jump_hop_gallery_card_view_row(
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
theme_text: row.work_title.clone(),
|
||||
work_title: row.work_title,
|
||||
work_description: row.work_description,
|
||||
cover_image_src: empty_string_to_none(row.cover_image_src),
|
||||
@@ -108,11 +129,13 @@ fn map_jump_hop_work_snapshot(
|
||||
template_id: "jump-hop".to_string(),
|
||||
template_name: "跳一跳".to_string(),
|
||||
profile_id: Some(snapshot.profile_id.clone()),
|
||||
theme_text: snapshot.work_title.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(),
|
||||
@@ -143,6 +166,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: snapshot.work_title.clone(),
|
||||
work_title: snapshot.work_title,
|
||||
work_description: snapshot.work_description,
|
||||
theme_tags: snapshot.theme_tags,
|
||||
@@ -159,6 +183,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
|
||||
@@ -170,15 +195,18 @@ fn map_jump_hop_work_snapshot(
|
||||
}
|
||||
|
||||
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
|
||||
let theme_text = snapshot.work_title.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,
|
||||
@@ -211,10 +239,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 +294,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 +319,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,
|
||||
|
||||
@@ -280,7 +280,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
},
|
||||
creation_types: creation_types
|
||||
.into_iter()
|
||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
||||
.map(|item| normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
@@ -299,7 +299,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
),
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||
})
|
||||
}))
|
||||
.collect(),
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
},
|
||||
@@ -332,19 +332,21 @@ 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,
|
||||
.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,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
@@ -358,6 +360,138 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
},
|
||||
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,
|
||||
}],
|
||||
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> {
|
||||
|
||||
@@ -365,6 +365,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;
|
||||
@@ -433,6 +434,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;
|
||||
@@ -1404,6 +1410,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;
|
||||
@@ -1472,6 +1479,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;
|
||||
@@ -2400,6 +2412,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>,
|
||||
@@ -2614,6 +2627,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)?,
|
||||
),
|
||||
@@ -3043,6 +3059,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",
|
||||
@@ -3528,6 +3550,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)?),
|
||||
@@ -3871,6 +3896,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)?),
|
||||
@@ -4130,6 +4158,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>,
|
||||
@@ -4422,6 +4451,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,
|
||||
@@ -5444,6 +5478,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);
|
||||
@@ -5556,6 +5591,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",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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};
|
||||
|
||||
use super::jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput;
|
||||
use super::jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct GetJumpHopLeaderboardArgs {
|
||||
pub input: JumpHopLeaderboardGetInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GetJumpHopLeaderboardArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `get_jump_hop_leaderboard`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait get_jump_hop_leaderboard {
|
||||
fn get_jump_hop_leaderboard(&self, input: JumpHopLeaderboardGetInput) {
|
||||
self.get_jump_hop_leaderboard_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn get_jump_hop_leaderboard_then(
|
||||
&self,
|
||||
input: JumpHopLeaderboardGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl get_jump_hop_leaderboard for super::RemoteProcedures {
|
||||
fn get_jump_hop_leaderboard_then(
|
||||
&self,
|
||||
input: JumpHopLeaderboardGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>(
|
||||
"get_jump_hop_leaderboard",
|
||||
GetJumpHopLeaderboardArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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 JumpHopLeaderboardEntryRow {
|
||||
pub entry_id: String,
|
||||
pub profile_id: String,
|
||||
pub player_id: String,
|
||||
pub successful_jump_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub run_id: String,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for JumpHopLeaderboardEntryRow {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `JumpHopLeaderboardEntryRow`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct JumpHopLeaderboardEntryRowCols {
|
||||
pub entry_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||
pub profile_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||
pub player_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||
pub successful_jump_count: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, u32>,
|
||||
pub duration_ms: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, u64>,
|
||||
pub run_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||
pub updated_at: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for JumpHopLeaderboardEntryRow {
|
||||
type Cols = JumpHopLeaderboardEntryRowCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
JumpHopLeaderboardEntryRowCols {
|
||||
entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"),
|
||||
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
|
||||
player_id: __sdk::__query_builder::Col::new(table_name, "player_id"),
|
||||
successful_jump_count: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"successful_jump_count",
|
||||
),
|
||||
duration_ms: __sdk::__query_builder::Col::new(table_name, "duration_ms"),
|
||||
run_id: __sdk::__query_builder::Col::new(table_name, "run_id"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `JumpHopLeaderboardEntryRow`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct JumpHopLeaderboardEntryRowIxCols {
|
||||
pub entry_id: __sdk::__query_builder::IxCol<JumpHopLeaderboardEntryRow, String>,
|
||||
pub profile_id: __sdk::__query_builder::IxCol<JumpHopLeaderboardEntryRow, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for JumpHopLeaderboardEntryRow {
|
||||
type IxCols = JumpHopLeaderboardEntryRowIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
JumpHopLeaderboardEntryRowIxCols {
|
||||
entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"),
|
||||
profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for JumpHopLeaderboardEntryRow {}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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 JumpHopLeaderboardEntrySnapshot {
|
||||
pub rank: u32,
|
||||
pub player_id: String,
|
||||
pub successful_jump_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for JumpHopLeaderboardEntrySnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// 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 super::jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `jump_hop_leaderboard_entry`.
|
||||
///
|
||||
/// Obtain a handle from the [`JumpHopLeaderboardEntryTableAccess::jump_hop_leaderboard_entry`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.jump_hop_leaderboard_entry()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.jump_hop_leaderboard_entry().on_insert(...)`.
|
||||
pub struct JumpHopLeaderboardEntryTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<JumpHopLeaderboardEntryRow>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `jump_hop_leaderboard_entry`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait JumpHopLeaderboardEntryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`JumpHopLeaderboardEntryTableHandle`], which mediates access to the table `jump_hop_leaderboard_entry`.
|
||||
fn jump_hop_leaderboard_entry(&self) -> JumpHopLeaderboardEntryTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl JumpHopLeaderboardEntryTableAccess for super::RemoteTables {
|
||||
fn jump_hop_leaderboard_entry(&self) -> JumpHopLeaderboardEntryTableHandle<'_> {
|
||||
JumpHopLeaderboardEntryTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<JumpHopLeaderboardEntryRow>("jump_hop_leaderboard_entry"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JumpHopLeaderboardEntryInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct JumpHopLeaderboardEntryDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for JumpHopLeaderboardEntryTableHandle<'ctx> {
|
||||
type Row = JumpHopLeaderboardEntryRow;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = JumpHopLeaderboardEntryRow> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = JumpHopLeaderboardEntryInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> JumpHopLeaderboardEntryInsertCallbackId {
|
||||
JumpHopLeaderboardEntryInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: JumpHopLeaderboardEntryInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = JumpHopLeaderboardEntryDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> JumpHopLeaderboardEntryDeleteCallbackId {
|
||||
JumpHopLeaderboardEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: JumpHopLeaderboardEntryDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JumpHopLeaderboardEntryUpdateCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopLeaderboardEntryTableHandle<'ctx> {
|
||||
type UpdateCallbackId = JumpHopLeaderboardEntryUpdateCallbackId;
|
||||
|
||||
fn on_update(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
|
||||
) -> JumpHopLeaderboardEntryUpdateCallbackId {
|
||||
JumpHopLeaderboardEntryUpdateCallbackId(self.imp.on_update(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_update(&self, callback: JumpHopLeaderboardEntryUpdateCallbackId) {
|
||||
self.imp.remove_on_update(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Access to the `entry_id` unique index on the table `jump_hop_leaderboard_entry`,
|
||||
/// which allows point queries on the field of the same name
|
||||
/// via the [`JumpHopLeaderboardEntryEntryIdUnique::find`] method.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.jump_hop_leaderboard_entry().entry_id().find(...)`.
|
||||
pub struct JumpHopLeaderboardEntryEntryIdUnique<'ctx> {
|
||||
imp: __sdk::UniqueConstraintHandle<JumpHopLeaderboardEntryRow, String>,
|
||||
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
impl<'ctx> JumpHopLeaderboardEntryTableHandle<'ctx> {
|
||||
/// Get a handle on the `entry_id` unique index on the table `jump_hop_leaderboard_entry`.
|
||||
pub fn entry_id(&self) -> JumpHopLeaderboardEntryEntryIdUnique<'ctx> {
|
||||
JumpHopLeaderboardEntryEntryIdUnique {
|
||||
imp: self.imp.get_unique_constraint::<String>("entry_id"),
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> JumpHopLeaderboardEntryEntryIdUnique<'ctx> {
|
||||
/// Find the subscribed row whose `entry_id` column value is equal to `col_val`,
|
||||
/// if such a row is present in the client cache.
|
||||
pub fn find(&self, col_val: &String) -> Option<JumpHopLeaderboardEntryRow> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<JumpHopLeaderboardEntryRow>("jump_hop_leaderboard_entry");
|
||||
_table.add_unique_constraint::<String>("entry_id", |row| &row.entry_id);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<JumpHopLeaderboardEntryRow>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<JumpHopLeaderboardEntryRow>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `JumpHopLeaderboardEntryRow`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait jump_hop_leaderboard_entryQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `JumpHopLeaderboardEntryRow`.
|
||||
fn jump_hop_leaderboard_entry(
|
||||
&self,
|
||||
) -> __sdk::__query_builder::Table<JumpHopLeaderboardEntryRow>;
|
||||
}
|
||||
|
||||
impl jump_hop_leaderboard_entryQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn jump_hop_leaderboard_entry(
|
||||
&self,
|
||||
) -> __sdk::__query_builder::Table<JumpHopLeaderboardEntryRow> {
|
||||
__sdk::__query_builder::Table::new("jump_hop_leaderboard_entry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 JumpHopLeaderboardGetInput {
|
||||
pub profile_id: String,
|
||||
pub viewer_player_id: String,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for JumpHopLeaderboardGetInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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};
|
||||
|
||||
use super::jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct JumpHopLeaderboardProcedureResult {
|
||||
pub ok: bool,
|
||||
pub profile_id: String,
|
||||
pub items: Vec<JumpHopLeaderboardEntrySnapshot>,
|
||||
pub viewer_best: Option<JumpHopLeaderboardEntrySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for JumpHopLeaderboardProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -9,7 +9,9 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
pub struct JumpHopRunJumpInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub charge_ms: u32,
|
||||
pub drag_distance: f32,
|
||||
pub drag_vector_x: Option<f32>,
|
||||
pub drag_vector_y: Option<f32>,
|
||||
pub client_event_id: String,
|
||||
pub jumped_at_ms: i64,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct JumpHopRunStartInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub runtime_mode: String,
|
||||
pub client_event_id: String,
|
||||
pub started_at_ms: i64,
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
#[sats(crate = __lib)]
|
||||
pub struct JumpHopTileAssetSnapshot {
|
||||
pub tile_type: String,
|
||||
pub tile_id: Option<String>,
|
||||
pub image_src: String,
|
||||
pub image_object_key: String,
|
||||
pub asset_object_id: String,
|
||||
pub source_atlas_cell: String,
|
||||
pub atlas_row: Option<u32>,
|
||||
pub atlas_col: Option<u32>,
|
||||
pub visual_width: u32,
|
||||
pub visual_height: u32,
|
||||
pub top_surface_radius: f32,
|
||||
|
||||
@@ -801,6 +801,7 @@ mod tests {
|
||||
|
||||
const SESSION_ID: &str = "wooden-fish-session-test";
|
||||
const OWNER_USER_ID: &str = "user-test";
|
||||
const AUTHOR_DISPLAY_NAME: &str = "木鱼作者";
|
||||
const PROFILE_ID: &str = "wooden-fish-profile-test";
|
||||
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
||||
|
||||
@@ -814,7 +815,13 @@ mod tests {
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
|
||||
let (plan, draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("compile-draft should build plan");
|
||||
|
||||
let WoodenFishActionProcedure::Compile(input) = plan else {
|
||||
@@ -863,7 +870,13 @@ mod tests {
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
match build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
) {
|
||||
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
|
||||
Err(error) => error,
|
||||
};
|
||||
@@ -884,7 +897,13 @@ mod tests {
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
match build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
) {
|
||||
Ok(_) => panic!("compile-draft should not publish without background asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
@@ -905,7 +924,13 @@ mod tests {
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
match build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
) {
|
||||
Ok(_) => panic!("compile-draft should not publish without back button asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
@@ -927,7 +952,13 @@ mod tests {
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
|
||||
|
||||
let (plan, _draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("regenerate-hit-object should build plan");
|
||||
|
||||
let WoodenFishActionProcedure::Compile(input) = plan else {
|
||||
@@ -988,7 +1019,13 @@ mod tests {
|
||||
]);
|
||||
|
||||
let (plan, draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("update-floating-words should build plan");
|
||||
|
||||
let WoodenFishActionProcedure::Update(input) = plan else {
|
||||
|
||||
@@ -245,6 +245,29 @@ pub fn restart_jump_hop_run(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_jump_hop_leaderboard(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: JumpHopLeaderboardGetInput,
|
||||
) -> JumpHopLeaderboardProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_jump_hop_leaderboard_tx(tx, input.clone())) {
|
||||
Ok((profile_id, items, viewer_best)) => JumpHopLeaderboardProcedureResult {
|
||||
ok: true,
|
||||
profile_id,
|
||||
items,
|
||||
viewer_best,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => JumpHopLeaderboardProcedureResult {
|
||||
ok: false,
|
||||
profile_id: input.profile_id,
|
||||
items: Vec::new(),
|
||||
viewer_best: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_jump_hop_agent_session_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: JumpHopAgentSessionCreateInput,
|
||||
@@ -543,6 +566,12 @@ fn start_jump_hop_run_tx(
|
||||
) -> Result<JumpHopRunSnapshot, String> {
|
||||
require_non_empty(&input.run_id, "jump_hop run_id")?;
|
||||
let work = find_work(ctx, &input.profile_id)?;
|
||||
let runtime_mode = normalize_runtime_mode(&input.runtime_mode);
|
||||
if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED
|
||||
&& work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED
|
||||
{
|
||||
return Err("jump_hop published runtime 只能启动已发布作品".to_string());
|
||||
}
|
||||
let path = parse_json::<JumpHopPath>(&work.path_json)?;
|
||||
let domain_run = start_run(
|
||||
input.run_id.clone(),
|
||||
@@ -554,7 +583,9 @@ fn start_jump_hop_run_tx(
|
||||
.map_err(|error| error.to_string())?;
|
||||
let snapshot = domain_run;
|
||||
upsert_run(ctx, &snapshot, input.started_at_ms);
|
||||
increment_work_play_count(ctx, &work, input.started_at_ms);
|
||||
if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED {
|
||||
increment_work_play_count(ctx, &work, input.started_at_ms);
|
||||
}
|
||||
insert_event(
|
||||
ctx,
|
||||
input.client_event_id,
|
||||
@@ -582,10 +613,19 @@ fn jump_hop_jump_tx(
|
||||
) -> Result<JumpHopRunSnapshot, String> {
|
||||
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
||||
let snapshot = parse_json::<JumpHopRunSnapshot>(&row.snapshot_json)?;
|
||||
let domain_next = apply_jump(&snapshot, input.charge_ms, input.jumped_at_ms as u64)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let domain_next = apply_jump(
|
||||
&snapshot,
|
||||
input.drag_distance,
|
||||
input.drag_vector_x,
|
||||
input.drag_vector_y,
|
||||
input.jumped_at_ms as u64,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let next = domain_next;
|
||||
replace_run(ctx, &row, &next, input.jumped_at_ms);
|
||||
if next.status == module_jump_hop::JumpHopRunStatus::Failed {
|
||||
upsert_jump_hop_leaderboard_entry(ctx, &next, input.jumped_at_ms);
|
||||
}
|
||||
insert_event(
|
||||
ctx,
|
||||
input.client_event_id,
|
||||
@@ -602,6 +642,47 @@ fn jump_hop_jump_tx(
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
fn get_jump_hop_leaderboard_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: JumpHopLeaderboardGetInput,
|
||||
) -> Result<
|
||||
(
|
||||
String,
|
||||
Vec<JumpHopLeaderboardEntrySnapshot>,
|
||||
Option<JumpHopLeaderboardEntrySnapshot>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
require_non_empty(&input.profile_id, "jump_hop profile_id")?;
|
||||
let _ = find_work(ctx, &input.profile_id)?;
|
||||
let limit = input.limit.clamp(1, 50) as usize;
|
||||
let mut rows = ctx
|
||||
.db
|
||||
.jump_hop_leaderboard_entry()
|
||||
.by_jump_hop_leaderboard_profile_id()
|
||||
.filter(input.profile_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
sort_jump_hop_leaderboard_rows(&mut rows);
|
||||
let ranked_rows = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| (index as u32 + 1, row))
|
||||
.collect::<Vec<_>>();
|
||||
let viewer_best = clean_optional(&input.viewer_player_id).and_then(|viewer_player_id| {
|
||||
ranked_rows
|
||||
.iter()
|
||||
.find(|(_, row)| row.player_id == viewer_player_id)
|
||||
.map(|(rank, row)| leaderboard_entry_snapshot(*rank, row))
|
||||
});
|
||||
let items = ranked_rows
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(rank, row)| leaderboard_entry_snapshot(rank, row))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok((input.profile_id, items, viewer_best))
|
||||
}
|
||||
|
||||
fn restart_jump_hop_run_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: JumpHopRunRestartInput,
|
||||
@@ -971,9 +1052,121 @@ fn insert_event(
|
||||
});
|
||||
}
|
||||
|
||||
fn normalize_runtime_mode(value: &str) -> &'static str {
|
||||
if value
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(JUMP_HOP_RUNTIME_MODE_DRAFT)
|
||||
{
|
||||
JUMP_HOP_RUNTIME_MODE_DRAFT
|
||||
} else {
|
||||
JUMP_HOP_RUNTIME_MODE_PUBLISHED
|
||||
}
|
||||
}
|
||||
|
||||
fn build_jump_hop_leaderboard_entry_id(player_id: &str, profile_id: &str) -> String {
|
||||
format!("jump-hop-leaderboard-{player_id}-{profile_id}")
|
||||
}
|
||||
|
||||
fn upsert_jump_hop_leaderboard_entry(
|
||||
ctx: &ReducerContext,
|
||||
snapshot: &JumpHopRunSnapshot,
|
||||
updated_at_ms: i64,
|
||||
) {
|
||||
let Some(finished_at_ms) = snapshot.finished_at_ms else {
|
||||
return;
|
||||
};
|
||||
let successful_jump_count = snapshot.current_platform_index;
|
||||
let duration_ms = finished_at_ms.saturating_sub(snapshot.started_at_ms);
|
||||
let entry_id =
|
||||
build_jump_hop_leaderboard_entry_id(&snapshot.owner_user_id, &snapshot.profile_id);
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000));
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.jump_hop_leaderboard_entry()
|
||||
.entry_id()
|
||||
.find(&entry_id)
|
||||
{
|
||||
let should_replace =
|
||||
is_jump_hop_leaderboard_candidate_better(successful_jump_count, duration_ms, &existing);
|
||||
ctx.db
|
||||
.jump_hop_leaderboard_entry()
|
||||
.entry_id()
|
||||
.delete(&entry_id);
|
||||
ctx.db
|
||||
.jump_hop_leaderboard_entry()
|
||||
.insert(JumpHopLeaderboardEntryRow {
|
||||
entry_id,
|
||||
profile_id: existing.profile_id,
|
||||
player_id: existing.player_id,
|
||||
successful_jump_count: if should_replace {
|
||||
successful_jump_count
|
||||
} else {
|
||||
existing.successful_jump_count
|
||||
},
|
||||
duration_ms: if should_replace {
|
||||
duration_ms
|
||||
} else {
|
||||
existing.duration_ms
|
||||
},
|
||||
run_id: if should_replace {
|
||||
snapshot.run_id.clone()
|
||||
} else {
|
||||
existing.run_id
|
||||
},
|
||||
updated_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.jump_hop_leaderboard_entry()
|
||||
.insert(JumpHopLeaderboardEntryRow {
|
||||
entry_id,
|
||||
profile_id: snapshot.profile_id.clone(),
|
||||
player_id: snapshot.owner_user_id.clone(),
|
||||
successful_jump_count,
|
||||
duration_ms,
|
||||
run_id: snapshot.run_id.clone(),
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn is_jump_hop_leaderboard_candidate_better(
|
||||
successful_jump_count: u32,
|
||||
duration_ms: u64,
|
||||
existing: &JumpHopLeaderboardEntryRow,
|
||||
) -> bool {
|
||||
successful_jump_count > existing.successful_jump_count
|
||||
|| (successful_jump_count == existing.successful_jump_count
|
||||
&& duration_ms < existing.duration_ms)
|
||||
}
|
||||
|
||||
fn sort_jump_hop_leaderboard_rows(rows: &mut [JumpHopLeaderboardEntryRow]) {
|
||||
rows.sort_by(|left, right| {
|
||||
right
|
||||
.successful_jump_count
|
||||
.cmp(&left.successful_jump_count)
|
||||
.then_with(|| left.duration_ms.cmp(&right.duration_ms))
|
||||
.then_with(|| left.updated_at.cmp(&right.updated_at))
|
||||
.then_with(|| left.player_id.cmp(&right.player_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn leaderboard_entry_snapshot(
|
||||
rank: u32,
|
||||
row: &JumpHopLeaderboardEntryRow,
|
||||
) -> JumpHopLeaderboardEntrySnapshot {
|
||||
JumpHopLeaderboardEntrySnapshot {
|
||||
rank,
|
||||
player_id: row.player_id.clone(),
|
||||
successful_jump_count: row.successful_jump_count,
|
||||
duration_ms: row.duration_ms,
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_publish_ready(row: &JumpHopWorkProfileRow) -> bool {
|
||||
!row.work_title.trim().is_empty()
|
||||
&& !row.character_asset_json.trim().is_empty()
|
||||
&& !row.tile_atlas_asset_json.trim().is_empty()
|
||||
&& !row.tile_assets_json.trim().is_empty()
|
||||
&& !row.path_json.trim().is_empty()
|
||||
@@ -985,8 +1178,8 @@ fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot {
|
||||
theme_text: seed.clone(),
|
||||
difficulty: JumpHopDifficulty::Standard.as_str().to_string(),
|
||||
style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(),
|
||||
character_prompt: format!("{seed}的俯视角主角,透明背景,全身可见"),
|
||||
tile_prompt: format!("{seed}的等距地块图集,包含起点、普通、目标和终点地块"),
|
||||
character_prompt: "内置默认 3D 角色".to_string(),
|
||||
tile_prompt: format!("{seed}主题的俯视角清爽游戏化立体感平台素材"),
|
||||
end_mood_prompt: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1185,3 +1378,64 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn leaderboard_row(
|
||||
player_id: &str,
|
||||
successful_jump_count: u32,
|
||||
duration_ms: u64,
|
||||
updated_at_micros: i64,
|
||||
) -> JumpHopLeaderboardEntryRow {
|
||||
JumpHopLeaderboardEntryRow {
|
||||
entry_id: format!("entry-{player_id}"),
|
||||
profile_id: "jump-hop-profile-test".to_string(),
|
||||
player_id: player_id.to_string(),
|
||||
successful_jump_count,
|
||||
duration_ms,
|
||||
run_id: format!("run-{player_id}"),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_leaderboard_sorts_by_jump_count_duration_and_update_time() {
|
||||
let mut rows = vec![
|
||||
leaderboard_row("player-slow", 8, 8_000, 30),
|
||||
leaderboard_row("player-late", 9, 6_000, 20),
|
||||
leaderboard_row("player-fast", 9, 5_000, 40),
|
||||
leaderboard_row("player-early", 9, 5_000, 10),
|
||||
];
|
||||
|
||||
sort_jump_hop_leaderboard_rows(&mut rows);
|
||||
|
||||
let player_ids = rows
|
||||
.into_iter()
|
||||
.map(|row| row.player_id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
player_ids,
|
||||
vec!["player-early", "player-fast", "player-late", "player-slow"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_leaderboard_replaces_only_better_player_score() {
|
||||
let existing = leaderboard_row("player", 6, 4_000, 10);
|
||||
|
||||
assert!(is_jump_hop_leaderboard_candidate_better(
|
||||
7, 8_000, &existing
|
||||
));
|
||||
assert!(is_jump_hop_leaderboard_candidate_better(
|
||||
6, 3_500, &existing
|
||||
));
|
||||
assert!(!is_jump_hop_leaderboard_candidate_better(
|
||||
6, 4_500, &existing
|
||||
));
|
||||
assert!(!is_jump_hop_leaderboard_candidate_better(
|
||||
5, 1_000, &existing
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,3 +94,19 @@ pub struct JumpHopEventRow {
|
||||
pub(crate) result: String,
|
||||
pub(crate) occurred_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = jump_hop_leaderboard_entry,
|
||||
index(accessor = by_jump_hop_leaderboard_profile_id, btree(columns = [profile_id])),
|
||||
index(accessor = by_jump_hop_leaderboard_player_profile, btree(columns = [player_id, profile_id]))
|
||||
)]
|
||||
pub struct JumpHopLeaderboardEntryRow {
|
||||
#[primary_key]
|
||||
pub(crate) entry_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) player_id: String,
|
||||
pub(crate) successful_jump_count: u32,
|
||||
pub(crate) duration_ms: u64,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub const JUMP_HOP_GENERATION_READY: &str = "ready";
|
||||
pub const JUMP_HOP_EVENT_RUN_STARTED: &str = "run-started";
|
||||
pub const JUMP_HOP_EVENT_RUN_RESTARTED: &str = "run-restarted";
|
||||
pub const JUMP_HOP_EVENT_JUMP: &str = "jump";
|
||||
pub const JUMP_HOP_RUNTIME_MODE_DRAFT: &str = "draft";
|
||||
pub const JUMP_HOP_RUNTIME_MODE_PUBLISHED: &str = "published";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct JumpHopAgentSessionCreateInput {
|
||||
@@ -96,6 +98,7 @@ pub struct JumpHopRunStartInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub runtime_mode: String,
|
||||
pub client_event_id: String,
|
||||
pub started_at_ms: i64,
|
||||
}
|
||||
@@ -106,11 +109,13 @@ pub struct JumpHopRunGetInput {
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct JumpHopRunJumpInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub charge_ms: u32,
|
||||
pub drag_distance: f32,
|
||||
pub drag_vector_x: Option<f32>,
|
||||
pub drag_vector_y: Option<f32>,
|
||||
pub client_event_id: String,
|
||||
pub jumped_at_ms: i64,
|
||||
}
|
||||
@@ -152,6 +157,31 @@ pub struct JumpHopRunProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct JumpHopLeaderboardEntrySnapshot {
|
||||
pub rank: u32,
|
||||
pub player_id: String,
|
||||
pub successful_jump_count: u32,
|
||||
pub duration_ms: u64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct JumpHopLeaderboardGetInput {
|
||||
pub profile_id: String,
|
||||
pub viewer_player_id: String,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct JumpHopLeaderboardProcedureResult {
|
||||
pub ok: bool,
|
||||
pub profile_id: String,
|
||||
pub items: Vec<JumpHopLeaderboardEntrySnapshot>,
|
||||
pub viewer_best: Option<JumpHopLeaderboardEntrySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopCreatorConfigSnapshot {
|
||||
@@ -181,10 +211,16 @@ pub struct JumpHopCharacterAssetSnapshot {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopTileAssetSnapshot {
|
||||
pub tile_type: String,
|
||||
#[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,
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::bark_battle::tables::{
|
||||
};
|
||||
use crate::big_fish::big_fish_runtime_run;
|
||||
use crate::jump_hop::tables::{
|
||||
jump_hop_agent_session, jump_hop_event, jump_hop_runtime_run, jump_hop_work_profile,
|
||||
jump_hop_agent_session, jump_hop_event, jump_hop_leaderboard_entry, jump_hop_runtime_run,
|
||||
jump_hop_work_profile,
|
||||
};
|
||||
use crate::match3d::tables::{
|
||||
match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run,
|
||||
@@ -244,6 +245,7 @@ macro_rules! migration_tables {
|
||||
jump_hop_work_profile,
|
||||
jump_hop_runtime_run,
|
||||
jump_hop_event,
|
||||
jump_hop_leaderboard_entry,
|
||||
wooden_fish_agent_session,
|
||||
wooden_fish_work_profile,
|
||||
wooden_fish_runtime_run,
|
||||
|
||||
@@ -237,6 +237,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
migrate_bark_battle_entry_to_open_default(ctx, now);
|
||||
migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now);
|
||||
migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now);
|
||||
migrate_jump_hop_entry_from_old_puzzle_default(ctx, now);
|
||||
}
|
||||
|
||||
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
@@ -388,6 +389,35 @@ fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext,
|
||||
});
|
||||
}
|
||||
|
||||
fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "jump-hop".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 中文注释:只纠偏跳一跳重设计前的系统默认入口,避免覆盖后台手动配置。
|
||||
let still_old_puzzle_default = row.title == "跳一跳"
|
||||
&& row.subtitle == "俯视角跳跃闯关"
|
||||
&& row.badge == "可创建"
|
||||
&& row.image_src == "/creation-type-references/puzzle.webp"
|
||||
&& row.visible
|
||||
&& row.open
|
||||
&& row.sort_order == 45;
|
||||
if !still_old_puzzle_default {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.creation_entry_type_config()
|
||||
.id()
|
||||
.update(CreationEntryTypeConfig {
|
||||
subtitle: "主题驱动平台跳跃".to_string(),
|
||||
image_src: "/creation-type-references/jump-hop.webp".to_string(),
|
||||
updated_at: now,
|
||||
..row
|
||||
});
|
||||
}
|
||||
|
||||
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
|
||||
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
|
||||
.into_iter()
|
||||
|
||||
Reference in New Issue
Block a user