Merge remote-tracking branch 'origin/master' into dev-jenken

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
#	server-rs/crates/api-server/src/modules/jump_hop.rs
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/jump-hop/jumpHopClient.test.ts
This commit is contained in:
2026-06-05 23:59:40 +08:00
67 changed files with 8713 additions and 2537 deletions

View File

@@ -270,6 +270,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();

View File

@@ -1,4 +1,4 @@
use axum::http::StatusCode;
use axum::http::StatusCode;
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
use crate::{
@@ -8,9 +8,12 @@ use crate::{
#[allow(unused_imports)]
pub(crate) use generated_asset_sheets_impl::{
GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,
};
pub(crate) fn build_generated_asset_sheet_prompt(

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,16 @@
use axum::{
Router, middleware,
middleware,
routing::{delete, get, post},
Router,
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
create_jump_hop_session, delete_jump_hop_work, 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_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works,
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
},
state::AppState,
};
@@ -62,6 +63,13 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/jump-hop/works/{profile_id}",
get(get_jump_hop_runtime_work),
)
.route(
"/api/runtime/jump-hop/works/{profile_id}/leaderboard",
get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/jump-hop/runs",
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(

View File

@@ -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());
}
}

View File

@@ -404,9 +404,9 @@ pub fn default_creation_entry_type_snapshots(
build_default_creation_entry_type_snapshot(
"jump-hop",
"跳一跳",
"俯视角跳跃闯关",
"主题驱动平台跳跃",
"可创建",
"/creation-type-references/puzzle.webp",
"/creation-type-references/jump-hop.webp",
true,
true,
45,

View File

@@ -446,6 +446,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);

View File

@@ -2,13 +2,80 @@ use super::color::{
GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE,
GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit,
compute_generated_asset_sheet_green_screen_score,
compute_generated_asset_sheet_key_color_score,
compute_generated_asset_sheet_white_screen_score,
is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel,
touches_generated_asset_sheet_background_mask,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetKeyColor {
pub red: u8,
pub green: u8,
pub blue: u8,
}
impl GeneratedAssetSheetKeyColor {
pub const GREEN_SCREEN: Self = Self {
red: 0,
green: 255,
blue: 0,
};
pub const MAGENTA_SCREEN: Self = Self {
red: 255,
green: 0,
blue: 255,
};
pub fn is_green_screen(self) -> bool {
self == Self::GREEN_SCREEN
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetAlphaOptions {
pub key_color: GeneratedAssetSheetKeyColor,
pub remove_near_white_background: bool,
pub remove_disconnected_hard_key_background: bool,
}
impl GeneratedAssetSheetAlphaOptions {
pub const fn green_screen() -> Self {
Self {
key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN,
remove_near_white_background: true,
remove_disconnected_hard_key_background: true,
}
}
pub const fn jump_hop_magenta_screen() -> Self {
Self {
key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN,
remove_near_white_background: false,
remove_disconnected_hard_key_background: false,
}
}
}
impl Default for GeneratedAssetSheetAlphaOptions {
fn default() -> Self {
Self::green_screen()
}
}
pub fn apply_generated_asset_sheet_green_screen_alpha(
source: image::DynamicImage,
) -> image::DynamicImage {
apply_generated_asset_sheet_alpha_with_options(
source,
GeneratedAssetSheetAlphaOptions::default(),
)
}
pub fn apply_generated_asset_sheet_alpha_with_options(
source: image::DynamicImage,
options: GeneratedAssetSheetAlphaOptions,
) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
@@ -16,6 +83,7 @@ pub fn apply_generated_asset_sheet_green_screen_alpha(
image.as_mut(),
width as usize,
height as usize,
options,
);
image::DynamicImage::ImageRgba8(image)
}
@@ -24,13 +92,14 @@ fn remove_generated_asset_sheet_green_screen_background(
pixels: &mut [u8],
width: usize,
height: usize,
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
let mut green_scores = vec![0.0f32; pixel_count];
let mut key_scores = vec![0.0f32; pixel_count];
let mut white_scores = vec![0.0f32; pixel_count];
let mut background_hints = vec![0.0f32; pixel_count];
let mut background_mask = vec![0u8; pixel_count];
@@ -43,16 +112,19 @@ fn remove_generated_asset_sheet_green_screen_background(
let green = pixels[offset + 1];
let blue = pixels[offset + 2];
let alpha = pixels[offset + 3];
let green_score =
compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]);
let white_score =
compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]);
let key_score =
compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color);
let white_score = if options.remove_near_white_background {
compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha])
} else {
0.0
};
let transparency_hint =
clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75;
green_scores[pixel_index] = green_score;
key_scores[pixel_index] = key_score;
white_scores[pixel_index] = white_score;
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
background_hints[pixel_index] = key_score.max(white_score).max(transparency_hint);
}
let seed_background_pixel =
@@ -62,10 +134,10 @@ fn remove_generated_asset_sheet_green_screen_background(
}
let alpha = pixels[pixel_index * 4 + 3];
let strong_candidate = alpha < 40
|| green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224
&& green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| white_scores[pixel_index] > 0.32;
&& key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
if !strong_candidate {
return;
}
@@ -113,26 +185,34 @@ fn remove_generated_asset_sheet_green_screen_background(
}
let next_offset = next_pixel_index * 4;
let alpha = pixels[next_offset + 3];
let green_score = green_scores[next_pixel_index];
let key_score = key_scores[next_pixel_index];
let white_score = white_scores[next_pixel_index];
let hint = background_hints[next_pixel_index];
let reachable_soft_edge = hint > 0.08
&& alpha < 224
&& (green_score > 0.04 || white_score > 0.08 || alpha < 180);
let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge {
&& (key_score > 0.04
|| (options.remove_near_white_background && white_score > 0.08)
|| alpha < 180);
let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
if alpha < 40
|| key_background
|| (options.remove_near_white_background && white_score > 0.32)
|| reachable_soft_edge
{
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0
&& green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
{
background_mask[pixel_index] = 1;
if options.remove_disconnected_hard_key_background {
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0
&& key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
{
background_mask[pixel_index] = 1;
}
}
}
@@ -153,10 +233,14 @@ fn remove_generated_asset_sheet_green_screen_background(
pixels[offset + 2],
pixels[offset + 3],
];
let green_score = green_scores[pixel_index];
let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index];
if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score)
{
if !is_generated_asset_sheet_soft_key_matte_pixel(
pixel,
key_score,
white_score,
options,
) {
continue;
}
if !touches_generated_asset_sheet_background_mask(
@@ -188,12 +272,12 @@ fn remove_generated_asset_sheet_green_screen_background(
continue;
}
let alpha = pixels[pixel_index * 4 + 3];
let green_score = green_scores[pixel_index];
let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index];
let hint = background_hints[pixel_index];
let soft_matte_candidate = alpha < 224
|| white_score > 0.10
|| green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
|| (options.remove_near_white_background && white_score > 0.10)
|| key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate {
continue;
}
@@ -278,9 +362,9 @@ fn remove_generated_asset_sheet_green_screen_background(
continue;
}
let green_score = green_scores[pixel_index];
let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index];
let contamination = green_score.max(white_score).max(if alpha < 220 {
let contamination = key_score.max(white_score).max(if alpha < 220 {
((220 - alpha) as f32 / 220.0) * 0.25
} else {
0.0
@@ -301,30 +385,47 @@ fn remove_generated_asset_sheet_green_screen_background(
let mut red = pixels[offset] as f32;
let mut green = pixels[offset + 1] as f32;
let mut blue = pixels[offset + 2] as f32;
let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22));
let blend = if options.key_color.is_green_screen() {
clamp_generated_asset_sheet_unit(contamination.max(0.22))
} else {
// 中文注释:洋红 / 青色等非绿幕 key 的残留更容易表现成彩边,
// 需要比绿幕更强地向主体邻近色收敛,避免 PNG 边缘继续带 key 色。
clamp_generated_asset_sheet_unit((key_score * 1.35).max(contamination).max(0.28))
};
if let Some((sample_red, sample_green, sample_blue)) = sample {
red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend);
green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend);
blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend);
if green_score > 0.04 {
if options.key_color.is_green_screen() && key_score > 0.04 {
green = green.min(sample_green as f32 + 18.0);
}
if white_score > 0.1 {
if options.remove_near_white_background && white_score > 0.1 {
red = red.min(sample_red as f32 + 26.0);
green = green.min(sample_green as f32 + 26.0);
blue = blue.min(sample_blue as f32 + 26.0);
}
if !options.key_color.is_green_screen() && key_score > 0.04 {
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[sample_red as f32, sample_green as f32, sample_blue as f32],
key_score,
options.key_color,
);
red = defringed[0];
green = defringed[1];
blue = defringed[2];
}
} else {
if green_score > 0.04 {
if options.key_color.is_green_screen() && key_score > 0.04 {
let toned_green = (green - (green - red.max(blue)) * 0.78)
.round()
.max(red.max(blue));
green = green.min(toned_green).min(red.max(blue) + 18.0);
}
if white_score > 0.12 {
if options.remove_near_white_background && white_score > 0.12 {
let spread = red.max(green).max(blue) - red.min(green).min(blue);
if spread < 20.0 {
let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
@@ -333,10 +434,26 @@ fn remove_generated_asset_sheet_green_screen_background(
blue = blue.min(toned_value);
}
}
if !options.key_color.is_green_screen() && key_score > 0.04 {
let neutral = (red + green + blue) / 3.0;
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[neutral, neutral, neutral],
key_score,
options.key_color,
);
red = defringed[0];
green = defringed[1];
blue = defringed[2];
}
}
let mut next_alpha = alpha;
let edge_fade = (green_score * 0.35).max(white_score * 0.28);
let edge_fade = if options.key_color.is_green_screen() {
(key_score * 0.35).max(white_score * 0.28)
} else {
(key_score * 0.48).max(white_score * 0.28)
};
if edge_fade > 0.08 {
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
if next_alpha < 10 {
@@ -364,6 +481,66 @@ fn remove_generated_asset_sheet_green_screen_background(
changed
}
pub(super) fn suppress_generated_asset_sheet_key_color_fringe(
color: [f32; 3],
target: [f32; 3],
key_score: f32,
key_color: GeneratedAssetSheetKeyColor,
) -> [f32; 3] {
let strength = clamp_generated_asset_sheet_unit(key_score * 1.18);
let key_channels = [
key_color.red as f32 / 255.0,
key_color.green as f32 / 255.0,
key_color.blue as f32 / 255.0,
];
let mut next = color;
for index in 0..3 {
if key_channels[index] >= 0.66 {
let cap = target[index] + 18.0 + (1.0 - strength) * 28.0;
next[index] = next[index].min(lerp_generated_asset_sheet_channel(
next[index],
cap,
strength,
));
} else if key_channels[index] <= 0.34 {
next[index] =
lerp_generated_asset_sheet_channel(next[index], target[index], strength * 0.72);
}
}
next
}
fn compute_generated_asset_sheet_key_score(
pixel: [u8; 4],
key_color: GeneratedAssetSheetKeyColor,
) -> f32 {
if key_color.is_green_screen() {
return compute_generated_asset_sheet_green_screen_score(pixel);
}
compute_generated_asset_sheet_key_color_score(
pixel,
[key_color.red, key_color.green, key_color.blue],
)
}
fn is_generated_asset_sheet_soft_key_matte_pixel(
pixel: [u8; 4],
key_score: f32,
white_score: f32,
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() {
return is_generated_asset_sheet_soft_green_matte_pixel(pixel, key_score, white_score);
}
pixel[3] != 0
&& key_score >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE
&& (!options.remove_near_white_background || white_score < 0.34)
}
fn collect_generated_asset_sheet_foreground_neighbor_color(
pixels: &[u8],
width: usize,

View File

@@ -139,6 +139,24 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -
.clamp(0.0, 1.0)
}
pub(super) fn compute_generated_asset_sheet_key_color_score(
pixel: [u8; 4],
key_color: [u8; 3],
) -> f32 {
if pixel[3] == 0 {
return 1.0;
}
let color_distance = (pixel[0] as f32 - key_color[0] as f32).abs()
+ (pixel[1] as f32 - key_color[1] as f32).abs()
+ (pixel[2] as f32 - key_color[2] as f32).abs();
if color_distance >= 180.0 {
return 0.0;
}
clamp_generated_asset_sheet_unit(1.0 - color_distance / 180.0)
}
pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;

View File

@@ -5,7 +5,10 @@ pub mod persist;
pub mod prompt;
pub mod sheet;
pub use alpha::apply_generated_asset_sheet_green_screen_alpha;
pub use alpha::{
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetKeyColor,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
};
pub use error::GeneratedAssetSheetError;
pub use persist::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload,
@@ -14,5 +17,6 @@ pub use persist::{
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
pub use sheet::{
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row,
crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
};

View File

@@ -1,10 +1,14 @@
use super::alpha::apply_generated_asset_sheet_green_screen_alpha;
use super::alpha::{
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha,
suppress_generated_asset_sheet_key_color_fringe,
};
use super::color::{
is_generated_asset_sheet_foreground_pixel,
clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_key_color_score,
compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel,
is_generated_asset_sheet_green_contaminated_edge_pixel,
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
touches_generated_asset_sheet_background_mask,
lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask,
};
use super::error::GeneratedAssetSheetError;
use image::{GenericImageView, ImageFormat};
@@ -130,10 +134,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row(
pub fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
crop_generated_asset_sheet_view_edge_matte_with_options(
image,
GeneratedAssetSheetAlphaOptions::default(),
)
}
pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
image: image::DynamicImage,
options: GeneratedAssetSheetAlphaOptions,
) -> image::DynamicImage {
let mut image = image.to_rgba8();
let (width, height) = image.dimensions();
remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize);
remove_generated_asset_sheet_view_edge_matte(
image.as_mut(),
width as usize,
height as usize,
options,
);
let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| {
GeneratedAssetSheetCellBounds {
x0: 0,
@@ -359,6 +378,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels: &mut [u8],
width: usize,
height: usize,
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
@@ -403,7 +423,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2],
pixels[offset + 3],
];
if !is_generated_asset_sheet_view_background_pixel(pixel) {
if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
continue;
}
background_mask[pixel_index] = 1;
@@ -434,7 +454,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2],
pixels[offset + 3],
];
if !is_generated_asset_sheet_view_background_pixel(pixel) {
if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
continue;
}
background_mask[next_pixel_index] = 1;
@@ -452,12 +472,15 @@ fn remove_generated_asset_sheet_view_edge_matte(
continue;
}
let offset = pixel_index * 4;
if !is_generated_asset_sheet_view_background_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
if !is_generated_asset_sheet_view_background_pixel_with_options(
[
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
],
options,
) {
continue;
}
@@ -526,7 +549,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2],
pixels[offset + 3],
];
if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) {
if !is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) {
continue;
}
if !touches_generated_asset_sheet_background_mask(
@@ -539,7 +562,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
continue;
}
if is_generated_asset_sheet_strong_green_contamination(pixel) {
if is_generated_asset_sheet_strong_key_contamination(pixel, options) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
@@ -559,17 +582,61 @@ fn remove_generated_asset_sheet_view_edge_matte(
y,
&background_mask,
&visible_mask,
options,
)
.unwrap_or((
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
));
let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement
.1
.min(next_red.max(next_blue).saturating_add(12));
let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() {
let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement
.1
.min(next_red.max(next_blue).saturating_add(12));
(next_red, next_green, next_blue)
} else {
let key_score = compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
);
let blend = clamp_generated_asset_sheet_unit((key_score * 1.25).max(0.36));
let red = lerp_generated_asset_sheet_channel(
pixels[offset] as f32,
replacement.0 as f32,
blend,
);
let green = lerp_generated_asset_sheet_channel(
pixels[offset + 1] as f32,
replacement.1 as f32,
blend,
);
let blue = lerp_generated_asset_sheet_channel(
pixels[offset + 2] as f32,
replacement.2 as f32,
blend,
);
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[
replacement.0 as f32,
replacement.1 as f32,
replacement.2 as f32,
],
key_score,
options.key_color,
);
(
defringed[0].round().clamp(0.0, 255.0) as u8,
defringed[1].round().clamp(0.0, 255.0) as u8,
defringed[2].round().clamp(0.0, 255.0) as u8,
)
};
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
@@ -605,6 +672,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
y: usize,
background_mask: &[u8],
visible_mask: &[u8],
options: GeneratedAssetSheetAlphaOptions,
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
@@ -638,8 +706,9 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
pixels[next_offset + 2],
next_alpha,
];
if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel)
|| is_generated_asset_sheet_soft_edge_pixel(pixel)
if is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options)
|| (options.key_color.is_green_screen()
&& is_generated_asset_sheet_soft_edge_pixel(pixel))
{
continue;
}
@@ -670,3 +739,73 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
(total_blue / total_weight).round() as u8,
))
}
fn is_generated_asset_sheet_view_background_pixel_with_options(
pixel: [u8; 4],
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() && options.remove_near_white_background {
return is_generated_asset_sheet_view_background_pixel(pixel);
}
if pixel[3] < 16 {
return true;
}
if options.key_color.is_green_screen() && is_generated_asset_sheet_soft_edge_pixel(pixel) {
return true;
}
if !options.key_color.is_green_screen()
&& compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
) > 0.18
{
return true;
}
options.remove_near_white_background
&& compute_generated_asset_sheet_white_screen_score(pixel) > 0.18
}
fn is_generated_asset_sheet_key_contaminated_edge_pixel(
pixel: [u8; 4],
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() {
return is_generated_asset_sheet_green_contaminated_edge_pixel(pixel);
}
pixel[3] != 0
&& compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
) > 0.18
}
fn is_generated_asset_sheet_strong_key_contamination(
pixel: [u8; 4],
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() {
return is_generated_asset_sheet_strong_green_contamination(pixel);
}
compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
) > 0.62
}

View File

@@ -2,9 +2,11 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use platform_image::DownloadedImage;
use platform_image::generated_asset_sheets::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha,
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetPersistInput,
GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,
prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
};
@@ -142,6 +144,140 @@ fn generated_asset_sheet_green_screen_alpha_removes_green_background() {
assert_eq!(cleaned.get_pixel(10, 10).0[3], 255);
}
#[test]
fn generated_asset_sheet_magenta_key_preserves_green_white_and_disconnected_key_subject() {
let mut sheet = RgbaImage::from_pixel(28, 28, Rgba([255, 0, 255, 255]));
for y in 6..22 {
for x in 6..14 {
sheet.put_pixel(x, y, Rgba([64, 188, 74, 255]));
}
}
for y in 6..22 {
for x in 14..22 {
sheet.put_pixel(x, y, Rgba([244, 244, 236, 255]));
}
}
for y in 12..16 {
for x in 12..16 {
sheet.put_pixel(x, y, Rgba([255, 0, 255, 255]));
}
}
let cleaned = apply_generated_asset_sheet_alpha_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
assert_eq!(cleaned.get_pixel(8, 8).0[3], 255);
assert_eq!(cleaned.get_pixel(18, 8).0[3], 255);
assert_eq!(
cleaned.get_pixel(13, 13).0[3],
255,
"非边缘连通的 key 色像素不应被当成背景清掉"
);
}
#[test]
fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() {
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([0, 0, 0, 0]));
for y in 2..22 {
for x in 2..22 {
sheet.put_pixel(x, y, Rgba([246, 246, 240, 255]));
}
}
for y in 0..24 {
sheet.put_pixel(0, y, Rgba([255, 0, 255, 255]));
sheet.put_pixel(23, y, Rgba([255, 0, 255, 255]));
}
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert_eq!(cleaned.get_pixel(1, 1).0[3], 255);
assert!(
cleaned
.pixels()
.any(|pixel| pixel.0 == [246, 246, 240, 255])
);
}
#[test]
fn generated_asset_sheet_magenta_alpha_defringes_pink_halo() {
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([255, 0, 255, 255]));
for y in 7..17 {
for x in 7..17 {
sheet.put_pixel(x, y, Rgba([198, 170, 120, 255]));
}
}
for y in 6..18 {
sheet.put_pixel(6, y, Rgba([226, 26, 218, 220]));
sheet.put_pixel(17, y, Rgba([226, 26, 218, 220]));
}
for x in 6..18 {
sheet.put_pixel(x, 6, Rgba([226, 26, 218, 220]));
sheet.put_pixel(x, 17, Rgba([226, 26, 218, 220]));
}
let cleaned = apply_generated_asset_sheet_alpha_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
let edge = cleaned.get_pixel(6, 12).0;
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
assert_eq!(cleaned.get_pixel(12, 12).0, [198, 170, 120, 255]);
if edge[3] > 0 {
assert!(
edge[0].saturating_sub(edge[1]) <= 76,
"红色 key 通道残留过强:{edge:?}"
);
assert!(
edge[2].saturating_sub(edge[1]) <= 76,
"蓝色 key 通道残留过强:{edge:?}"
);
}
}
#[test]
fn generated_asset_sheet_magenta_edge_matte_defringes_bottom_shadow() {
let mut sheet = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0]));
for y in 8..18 {
for x in 10..22 {
sheet.put_pixel(x, y, Rgba([202, 176, 126, 255]));
}
}
for y in 18..22 {
for x in 9..23 {
sheet.put_pixel(x, y, Rgba([224, 30, 220, 186]));
}
}
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert!(
cleaned
.pixels()
.any(|pixel| pixel.0 == [202, 176, 126, 255])
);
assert!(
!cleaned.pixels().any(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha > 0 && red > 200 && blue > 200 && green < 96
}),
"底部洋红残影应被删除或去彩边"
);
}
#[test]
fn generated_asset_sheet_view_edge_matte_trims_transparent_border() {
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));

View File

@@ -44,7 +44,6 @@ pub enum JumpHopTileType {
#[serde(rename_all = "kebab-case")]
pub enum JumpHopActionType {
CompileDraft,
RegenerateCharacter,
RegenerateTiles,
UpdateWorkMeta,
UpdateDifficulty,
@@ -71,12 +70,20 @@ pub enum JumpHopJumpResult {
#[serde(rename_all = "camelCase")]
pub struct JumpHopWorkspaceCreateRequest {
pub template_id: String,
pub theme_text: String,
#[serde(default)]
pub work_title: String,
#[serde(default)]
pub work_description: String,
#[serde(default)]
pub theme_tags: Vec<String>,
#[serde(default = "default_jump_hop_difficulty")]
pub difficulty: JumpHopDifficulty,
#[serde(default = "default_jump_hop_style_preset")]
pub style_preset: JumpHopStylePreset,
#[serde(default)]
pub character_prompt: String,
#[serde(default)]
pub tile_prompt: String,
#[serde(default)]
pub end_mood_prompt: Option<String>,
@@ -89,6 +96,8 @@ pub struct JumpHopActionRequest {
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub theme_text: Option<String>,
#[serde(default)]
pub work_title: Option<String>,
#[serde(default)]
pub work_description: Option<String>,
@@ -112,6 +121,8 @@ pub struct JumpHopActionRequest {
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
#[serde(default)]
pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -127,14 +138,30 @@ pub struct JumpHopCharacterAsset {
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopDefaultCharacter {
pub character_id: String,
pub display_name: String,
pub model_kind: String,
pub body_color: String,
pub accent_color: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopTileAsset {
pub tile_type: JumpHopTileType,
#[serde(default)]
pub tile_id: Option<String>,
pub image_src: String,
pub image_object_key: String,
pub asset_object_id: String,
pub source_atlas_cell: String,
#[serde(default)]
pub atlas_row: Option<u32>,
#[serde(default)]
pub atlas_col: Option<u32>,
pub visual_width: u32,
pub visual_height: u32,
pub top_surface_radius: f32,
@@ -193,11 +220,14 @@ pub struct JumpHopDraftResponse {
pub template_name: String,
#[serde(default)]
pub profile_id: Option<String>,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
pub difficulty: JumpHopDifficulty,
pub style_preset: JumpHopStylePreset,
#[serde(default)]
pub default_character: Option<JumpHopDefaultCharacter>,
pub character_prompt: String,
pub tile_prompt: String,
#[serde(default)]
@@ -212,6 +242,8 @@ pub struct JumpHopDraftResponse {
pub path: Option<JumpHopPath>,
#[serde(default)]
pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
pub generation_status: JumpHopGenerationStatus,
}
@@ -251,6 +283,7 @@ pub struct JumpHopWorkSummaryResponse {
pub owner_user_id: String,
#[serde(default)]
pub source_session_id: Option<String>,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -274,9 +307,13 @@ pub struct JumpHopWorkProfileResponse {
pub summary: JumpHopWorkSummaryResponse,
pub draft: JumpHopDraftResponse,
pub path: JumpHopPath,
#[serde(default)]
pub default_character: Option<JumpHopDefaultCharacter>,
pub character_asset: JumpHopCharacterAsset,
pub tile_atlas_asset: JumpHopCharacterAsset,
pub tile_assets: Vec<JumpHopTileAsset>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -305,6 +342,7 @@ pub struct JumpHopGalleryCardResponse {
pub profile_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
#[serde(default)]
@@ -343,6 +381,8 @@ pub struct JumpHopRuntimeRunSnapshotResponse {
pub owner_user_id: String,
pub status: JumpHopRunStatus,
pub current_platform_index: u32,
pub successful_jump_count: u32,
pub duration_ms: u64,
pub score: u32,
pub combo: u32,
pub path: JumpHopPath,
@@ -363,15 +403,29 @@ pub struct JumpHopRunResponse {
#[serde(rename_all = "camelCase")]
pub struct JumpHopStartRunRequest {
pub profile_id: String,
#[serde(default)]
pub runtime_mode: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopJumpRequest {
pub charge_ms: u32,
pub drag_distance: f32,
#[serde(default)]
pub drag_vector_x: Option<f32>,
#[serde(default)]
pub drag_vector_y: Option<f32>,
pub client_event_id: String,
}
fn default_jump_hop_difficulty() -> JumpHopDifficulty {
JumpHopDifficulty::Standard
}
fn default_jump_hop_style_preset() -> JumpHopStylePreset {
JumpHopStylePreset::MinimalBlocks
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopRestartRunRequest {
@@ -384,6 +438,25 @@ pub struct JumpHopJumpResponse {
pub run: JumpHopRuntimeRunSnapshotResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopLeaderboardEntry {
pub rank: u32,
pub player_id: String,
pub successful_jump_count: u32,
pub duration_ms: u64,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopLeaderboardResponse {
pub profile_id: String,
pub items: Vec<JumpHopLeaderboardEntry>,
#[serde(default)]
pub viewer_best: Option<JumpHopLeaderboardEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -393,6 +466,7 @@ mod tests {
fn jump_hop_workspace_request_uses_camel_case() {
let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest {
template_id: "jump-hop".to_string(),
theme_text: "跳一跳".to_string(),
work_title: "跳一跳".to_string(),
work_description: "俯视角跳跃闯关".to_string(),
theme_tags: vec!["休闲".to_string()],

View File

@@ -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;
@@ -253,7 +253,7 @@ impl SpacetimeClient {
let work = self
.get_jump_hop_work_profile(profile_id, String::new())
.await?;
validate_jump_hop_runtime_ready(&work)?;
validate_jump_hop_runtime_ready(&work, "published")?;
Ok(work)
}
@@ -262,17 +262,24 @@ impl SpacetimeClient {
payload: JumpHopStartRunRequest,
owner_user_id: String,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref());
let profile_id = payload.profile_id;
let work_owner_user_id = if runtime_mode == "draft" {
owner_user_id.clone()
} else {
String::new()
};
let work = self
.get_jump_hop_work_profile(profile_id.clone(), String::new())
.get_jump_hop_work_profile(profile_id.clone(), work_owner_user_id)
.await?;
validate_jump_hop_runtime_ready(&work)?;
validate_jump_hop_runtime_ready(&work, runtime_mode)?;
let run_id = build_prefixed_uuid_id("jump-hop-run-");
let procedure_input = JumpHopRunStartInput {
client_event_id: format!("{run_id}:start"),
run_id,
owner_user_id,
profile_id,
runtime_mode: runtime_mode.to_string(),
started_at_ms: current_unix_micros().div_euclid(1000),
};
self.start_jump_hop_run_with_input(procedure_input).await
@@ -327,7 +334,9 @@ impl SpacetimeClient {
let procedure_input = JumpHopRunJumpInput {
run_id,
owner_user_id,
charge_ms: payload.charge_ms,
drag_distance: payload.drag_distance,
drag_vector_x: payload.drag_vector_x,
drag_vector_y: payload.drag_vector_y,
client_event_id: payload.client_event_id,
jumped_at_ms: current_unix_micros().div_euclid(1000),
};
@@ -420,13 +429,39 @@ impl SpacetimeClient {
self.get_jump_hop_work_profile(card.profile_id, String::new())
.await
}
pub async fn get_jump_hop_leaderboard(
&self,
profile_id: String,
viewer_player_id: String,
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
let procedure_input = JumpHopLeaderboardGetInput {
profile_id,
viewer_player_id,
limit: 50,
};
self.call_after_connect("get_jump_hop_leaderboard", move |connection, sender| {
connection.procedures().get_jump_hop_leaderboard_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_leaderboard_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
}
fn validate_jump_hop_runtime_ready(
work: &JumpHopWorkProfileResponse,
runtime_mode: &str,
) -> Result<(), SpacetimeClientError> {
let status = work.summary.publication_status.trim().to_ascii_lowercase();
if status != "published" {
if runtime_mode == "published" && status != "published" {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 只能启动已发布作品",
));
@@ -436,11 +471,11 @@ fn validate_jump_hop_runtime_ready(
"jump-hop runtime 需要 ready 状态作品",
));
}
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
if work.tile_assets.is_empty() {
validate_jump_hop_default_character_ready(work)?;
validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
if work.tile_assets.len() < 25 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 缺少地块资产",
"jump-hop runtime 需要 25 个地块资产",
));
}
for (index, asset) in work.tile_assets.iter().enumerate() {
@@ -461,7 +496,34 @@ fn validate_jump_hop_runtime_ready(
Ok(())
}
fn validate_jump_hop_character_asset_ready(
fn normalize_jump_hop_runtime_mode(value: Option<&str>) -> &'static str {
if value
.map(|value| value.trim().eq_ignore_ascii_case("draft"))
.unwrap_or(false)
{
"draft"
} else {
"published"
}
}
fn validate_jump_hop_default_character_ready(
work: &JumpHopWorkProfileResponse,
) -> Result<(), SpacetimeClientError> {
let Some(default_character) = work.default_character.as_ref() else {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 缺少内置默认角色配置",
));
};
if default_character.model_kind.trim() != "builtin-three" {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 默认角色必须使用 builtin-three",
));
}
Ok(())
}
fn validate_jump_hop_tile_atlas_asset_ready(
asset: &JumpHopCharacterAsset,
field: &str,
) -> Result<(), SpacetimeClientError> {
@@ -499,7 +561,6 @@ enum JumpHopActionProcedure {
#[derive(Clone, Copy)]
enum JumpHopDraftMergeScope {
CompileDraft,
RegenerateCharacter,
RegenerateTiles,
UpdateWorkMeta,
UpdateDifficulty,
@@ -508,7 +569,6 @@ enum JumpHopDraftMergeScope {
#[derive(Clone, Copy)]
enum JumpHopAssetRefresh {
Preserve,
Character,
Tiles,
}
@@ -520,12 +580,18 @@ fn build_jump_hop_action_plan(
) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> {
let scope = match payload.action_type {
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter,
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty,
};
let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?;
let mut base_draft = current.draft.clone();
if matches!(payload.action_type, JumpHopActionType::RegenerateTiles) {
if let Some(draft) = base_draft.as_mut() {
draft.tile_atlas_asset = None;
draft.tile_assets.clear();
}
}
let mut draft = merge_action_into_draft(base_draft, payload, scope)?;
let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?;
draft.profile_id = Some(profile_id.clone());
@@ -538,16 +604,6 @@ fn build_jump_hop_action_plan(
JumpHopAssetRefresh::Preserve,
now_micros,
)?),
JumpHopActionType::RegenerateCharacter => {
JumpHopActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
&profile_id,
&mut draft,
JumpHopAssetRefresh::Character,
now_micros,
)?)
}
JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
@@ -587,6 +643,13 @@ fn merge_action_into_draft(
{
draft.work_title = value.trim().to_string();
}
if let Some(value) = payload
.theme_text
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.theme_text = value.trim().chars().take(60).collect();
}
if let Some(value) = payload.work_description.as_ref() {
draft.work_description = value.trim().to_string();
}
@@ -614,10 +677,7 @@ fn merge_action_into_draft(
.filter(|value| !value.is_empty());
}
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
) {
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(value) = payload
.character_prompt
.as_ref()
@@ -646,10 +706,7 @@ fn merge_action_into_draft(
{
draft.profile_id = Some(profile_id.to_string());
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
) {
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(asset) = payload.character_asset.clone() {
draft.character_asset = Some(asset);
}
@@ -673,6 +730,14 @@ fn merge_action_into_draft(
{
draft.cover_composite = Some(value.to_string());
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
) {
if let Some(asset) = payload.back_button_asset.clone() {
draft.back_button_asset = Some(asset);
}
}
if draft.work_title.trim().is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop work_title 不能为空",
@@ -689,28 +754,19 @@ fn build_compile_input(
refresh: JumpHopAssetRefresh,
now_micros: i64,
) -> Result<JumpHopDraftCompileInput, SpacetimeClientError> {
let force_character = matches!(refresh, JumpHopAssetRefresh::Character);
let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles);
if force_character {
draft.character_asset = None;
}
if force_tiles {
draft.tile_atlas_asset = None;
draft.tile_assets.clear();
}
let character_asset = draft.character_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
)
})?;
let character_asset = draft.character_asset.clone().unwrap_or_else(|| {
build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str())
});
draft.character_asset = Some(character_asset.clone());
draft.default_character = Some(default_jump_hop_default_character());
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
)
})?;
let tile_assets = if draft.tile_assets.is_empty() {
let tile_assets = if draft.tile_assets.len() < 25 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
"jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
));
} else {
draft.tile_assets.clone()
@@ -729,7 +785,7 @@ fn build_compile_input(
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
theme_text: Some(draft.work_title.clone()),
theme_text: Some(draft.theme_text.clone()),
difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()),
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
character_prompt: Some(draft.character_prompt.clone()),
@@ -739,6 +795,11 @@ fn build_compile_input(
tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?),
tile_assets_json: Some(json_string(&tile_assets)?),
cover_composite,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
generation_status: Some("ready".to_string()),
compiled_at_micros: now_micros,
})
@@ -809,26 +870,29 @@ fn default_draft() -> JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(),
work_title: JUMP_HOP_TEMPLATE_NAME.to_string(),
work_description: "俯视角跳跃闯关".to_string(),
theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
character_prompt: "俯视角可爱主角,透明背景".to_string(),
tile_prompt: "等距立体地块图集".to_string(),
default_character: Some(default_jump_hop_default_character()),
character_prompt: "内置默认 3D 角色".to_string(),
tile_prompt: "跳一跳主题的正面30度视角主题物体图集物体本身作为跳跃落点".to_string(),
end_mood_prompt: None,
character_asset: None,
tile_atlas_asset: None,
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeClientError> {
serde_json::to_string(&serde_json::json!({
"themeText": draft.work_title,
"themeText": draft.theme_text,
"difficulty": difficulty_to_str(&draft.difficulty),
"stylePreset": style_to_str(&draft.style_preset),
"characterPrompt": draft.character_prompt,
@@ -838,94 +902,6 @@ fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeCl
.map_err(SpacetimeClientError::validation_failed)
}
fn ensure_character_asset(
existing: Option<JumpHopCharacterAsset>,
profile_id: &str,
prompt: &str,
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-character{suffix}"),
image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
asset_object_id: format!("{profile_id}-character{suffix}-object"),
generation_provider: "deterministic-placeholder".to_string(),
prompt: prompt.to_string(),
width: 768,
height: 768,
}
}
fn ensure_tile_atlas_asset(
existing: Option<JumpHopCharacterAsset>,
profile_id: &str,
prompt: &str,
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-tile-atlas{suffix}"),
image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"),
generation_provider: "deterministic-placeholder".to_string(),
prompt: prompt.to_string(),
width: 1024,
height: 1024,
}
}
fn ensure_tile_assets(
existing: Vec<JumpHopTileAsset>,
profile_id: &str,
force_new: bool,
now_micros: i64,
) -> Vec<JumpHopTileAsset> {
if !force_new && !existing.is_empty() {
return existing;
}
let suffix = asset_revision_suffix(force_new.then_some(now_micros));
[
JumpHopTileType::Start,
JumpHopTileType::Normal,
JumpHopTileType::Target,
JumpHopTileType::Finish,
JumpHopTileType::Bonus,
JumpHopTileType::Accent,
]
.into_iter()
.enumerate()
.map(|(index, tile_type)| JumpHopTileAsset {
tile_type,
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"),
image_object_key: format!(
"generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"
),
asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"),
source_atlas_cell: format!("cell-{index}{suffix}"),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
})
.collect()
}
fn resolve_cover_composite(
draft: &JumpHopDraftResponse,
profile_id: &str,
@@ -950,6 +926,22 @@ fn resolve_cover_composite(
))
}
fn build_jump_hop_default_character_asset(
profile_id: &str,
theme_text: &str,
) -> JumpHopCharacterAsset {
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-builtin-character"),
image_src: "builtin://jump-hop/default-character".to_string(),
image_object_key: String::new(),
asset_object_id: format!("{profile_id}-builtin-character"),
generation_provider: "builtin-three".to_string(),
prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
width: 0,
height: 0,
}
}
fn asset_revision_suffix(revision: Option<i64>) -> String {
revision
.filter(|value| *value > 0)
@@ -981,6 +973,16 @@ fn style_to_str(value: &JumpHopStylePreset) -> &'static str {
}
}
fn default_jump_hop_default_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
shared_contracts::jump_hop::JumpHopDefaultCharacter {
character_id: "jump-hop-default-runner".to_string(),
display_name: "默认角色".to_string(),
model_kind: "builtin-three".to_string(),
body_color: "#f59e0b".to_string(),
accent_color: "#2563eb".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -992,8 +994,9 @@ mod tests {
const NOW_MICROS: i64 = 1_763_456_789_000_000;
#[test]
fn jump_hop_action_compile_draft_builds_compile_input_with_assets() {
let session = session_with_draft(draft_without_assets());
fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character()
{
let session = session_with_draft(draft_without_character_asset());
let payload = action(JumpHopActionType::CompileDraft);
let (plan, draft) =
@@ -1011,7 +1014,7 @@ mod tests {
.character_asset_json
.as_deref()
.unwrap_or("")
.contains("-character")
.contains("builtin-three")
);
assert!(
input
@@ -1025,59 +1028,19 @@ mod tests {
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("tile-0-object")
.contains("old-tile-25-object")
);
assert_eq!(draft.tile_assets.len(), 25);
assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready);
}
#[test]
fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::RegenerateCharacter);
payload.character_prompt = Some("新的主角提示词".to_string());
let (plan, _draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("regenerate-character should build plan");
let JumpHopActionProcedure::Compile(input) = plan else {
panic!("regenerate-character should call compile_jump_hop_draft");
};
assert!(
!input
.character_asset_json
.as_deref()
.unwrap_or("")
.contains("old-character")
);
assert!(
input
.character_asset_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains("old-tile-atlas")
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("old-normal-tile")
);
}
#[test]
fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::RegenerateTiles);
payload.tile_prompt = Some("新的地块提示词".to_string());
payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS));
payload.tile_assets = Some(tile_assets("new", 25));
let (plan, _draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
@@ -1091,7 +1054,7 @@ mod tests {
.character_asset_json
.as_deref()
.unwrap_or("")
.contains("old-character")
.contains("builtin-three")
);
assert!(
!input
@@ -1105,24 +1068,43 @@ mod tests {
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("old-normal-tile")
.contains("old-tile-01-object")
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
.contains("new-tile-atlas")
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
.contains("new-tile-25-object")
);
}
#[test]
fn jump_hop_action_compile_draft_persists_theme_text_separately_from_title() {
let session = session_with_draft(draft_without_character_asset());
let mut payload = action(JumpHopActionType::CompileDraft);
payload.theme_text = Some(" 森林蘑菇跳台 ".to_string());
payload.work_title = Some("自动标题".to_string());
let (plan, draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("compile-draft should build plan");
let JumpHopActionProcedure::Compile(input) = plan else {
panic!("compile-draft should call compile_jump_hop_draft");
};
assert_eq!(draft.theme_text, "森林蘑菇跳台");
assert_eq!(input.theme_text.as_deref(), Some("森林蘑菇跳台"));
assert_eq!(input.work_title, "自动标题");
}
#[test]
fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() {
let session = session_with_draft(draft_with_assets());
@@ -1167,22 +1149,22 @@ mod tests {
.character_asset
.as_ref()
.map(|asset| asset.asset_id.as_str()),
Some("old-character")
Some("jump-hop-profile-test-builtin-character")
);
assert_eq!(
draft
.tile_assets
.first()
.map(|asset| asset.asset_object_id.as_str()),
Some("old-normal-tile-object")
Some("old-tile-01-object")
);
}
/// 构造不携带资产覆盖的 JumpHop action单测按需再覆盖字段。
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
JumpHopActionRequest {
action_type,
profile_id: None,
theme_text: None,
work_title: None,
work_description: None,
theme_tags: None,
@@ -1209,9 +1191,11 @@ mod tests {
}
}
fn draft_without_assets() -> JumpHopDraftResponse {
fn draft_without_character_asset() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: None,
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
tile_assets: tile_assets("old", 25),
..base_draft()
}
}
@@ -1219,37 +1203,9 @@ mod tests {
fn draft_with_assets() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: Some(PROFILE_ID.to_string()),
character_asset: Some(JumpHopCharacterAsset {
asset_id: "old-character".to_string(),
image_src: "/generated-jump-hop-assets/old-character.png".to_string(),
image_object_key: "generated-jump-hop-assets/old-character.png".to_string(),
asset_object_id: "old-character-object".to_string(),
generation_provider: "old-provider".to_string(),
prompt: "旧角色提示词".to_string(),
width: 768,
height: 768,
}),
tile_atlas_asset: Some(JumpHopCharacterAsset {
asset_id: "old-tile-atlas".to_string(),
image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(),
image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(),
asset_object_id: "old-tile-atlas-object".to_string(),
generation_provider: "old-provider".to_string(),
prompt: "旧地块提示词".to_string(),
width: 1024,
height: 1024,
}),
tile_assets: vec![JumpHopTileAsset {
tile_type: JumpHopTileType::Normal,
image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(),
image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(),
asset_object_id: "old-normal-tile-object".to_string(),
source_atlas_cell: "old-cell".to_string(),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
}],
character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")),
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
tile_assets: tile_assets("old", 25),
path: Some(sample_jump_hop_path()),
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
generation_status: JumpHopGenerationStatus::Ready,
@@ -1257,16 +1213,58 @@ mod tests {
}
}
fn tile_atlas_asset(asset_id: &str, revision: i64) -> JumpHopCharacterAsset {
let suffix = asset_revision_suffix((revision > 0).then_some(revision));
JumpHopCharacterAsset {
asset_id: asset_id.to_string(),
image_src: format!("/generated-jump-hop-assets/{asset_id}{suffix}.png"),
image_object_key: format!("generated-jump-hop-assets/{asset_id}{suffix}.png"),
asset_object_id: format!("{asset_id}-object"),
generation_provider: "vector-engine-image2".to_string(),
prompt: "旧地块提示词".to_string(),
width: 1024,
height: 1024,
}
}
fn tile_assets(prefix: &str, count: usize) -> Vec<JumpHopTileAsset> {
(0..count)
.map(|index| JumpHopTileAsset {
tile_type: if index == 0 {
JumpHopTileType::Start
} else {
JumpHopTileType::Normal
},
tile_id: Some(format!("tile-{:02}", index + 1)),
image_src: format!("/generated-jump-hop-assets/{prefix}-tile-{}.png", index + 1),
image_object_key: format!(
"generated-jump-hop-assets/{prefix}-tile-{}.png",
index + 1
),
asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1),
source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1),
atlas_row: Some(index as u32 / 5 + 1),
atlas_col: Some(index as u32 % 5 + 1),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
})
.collect()
}
fn base_draft() -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
theme_text: "旧主题".to_string(),
work_title: "旧标题".to_string(),
work_description: "旧描述".to_string(),
theme_tags: vec!["旧标签".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
default_character: Some(default_jump_hop_default_character()),
character_prompt: "旧角色提示词".to_string(),
tile_prompt: "旧地块提示词".to_string(),
end_mood_prompt: None,

View File

@@ -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,

View File

@@ -1,10 +1,11 @@
use super::*;
pub use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
JumpHopDefaultCharacter, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump,
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopPath, JumpHopPlatform,
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
@@ -61,15 +62,40 @@ pub(crate) fn map_jump_hop_run_procedure_result(
Ok(map_jump_hop_run_snapshot(run))
}
pub(crate) fn map_jump_hop_leaderboard_procedure_result(
result: JumpHopLeaderboardProcedureResult,
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(JumpHopLeaderboardResponse {
profile_id: result.profile_id,
items: result
.items
.into_iter()
.map(map_jump_hop_leaderboard_entry_snapshot)
.collect(),
viewer_best: result
.viewer_best
.map(map_jump_hop_leaderboard_entry_snapshot),
})
}
pub(crate) fn map_jump_hop_gallery_card_view_row(
row: JumpHopGalleryCardViewRow,
) -> JumpHopGalleryCardResponse {
let theme_text = if row.theme_text.trim().is_empty() {
row.work_title.clone()
} else {
row.theme_text.clone()
};
JumpHopGalleryCardResponse {
public_work_code: row.public_work_code,
work_id: row.work_id,
profile_id: row.profile_id,
owner_user_id: row.owner_user_id,
author_display_name: row.author_display_name,
theme_text,
work_title: row.work_title,
work_description: row.work_description,
cover_image_src: empty_string_to_none(row.cover_image_src),
@@ -104,15 +130,22 @@ fn map_jump_hop_session_snapshot(
fn map_jump_hop_work_snapshot(
snapshot: JumpHopWorkSnapshot,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
let theme_text = if snapshot.theme_text.trim().is_empty() {
snapshot.work_title.clone()
} else {
snapshot.theme_text.clone()
};
let draft = JumpHopDraftResponse {
template_id: "jump-hop".to_string(),
template_name: "跳一跳".to_string(),
profile_id: Some(snapshot.profile_id.clone()),
theme_text: theme_text.clone(),
work_title: snapshot.work_title.clone(),
work_description: snapshot.work_description.clone(),
theme_tags: snapshot.theme_tags.clone(),
difficulty: parse_difficulty(&snapshot.difficulty),
style_preset: parse_style_preset(&snapshot.style_preset),
default_character: Some(default_jump_hop_character()),
character_prompt: snapshot.character_prompt.clone(),
tile_prompt: snapshot.tile_prompt.clone(),
end_mood_prompt: snapshot.end_mood_prompt.clone(),
@@ -126,6 +159,7 @@ fn map_jump_hop_work_snapshot(
.collect(),
path: Some(map_jump_hop_path(snapshot.path.clone())),
cover_composite: snapshot.cover_composite.clone(),
back_button_asset: snapshot.back_button_asset.clone().map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status),
};
let character_asset = draft
@@ -143,6 +177,7 @@ fn map_jump_hop_work_snapshot(
profile_id: snapshot.profile_id,
owner_user_id: snapshot.owner_user_id,
source_session_id: empty_string_to_none(snapshot.source_session_id),
theme_text,
work_title: snapshot.work_title,
work_description: snapshot.work_description,
theme_tags: snapshot.theme_tags,
@@ -159,6 +194,7 @@ fn map_jump_hop_work_snapshot(
},
draft,
path: map_jump_hop_path(snapshot.path),
default_character: Some(default_jump_hop_character()),
character_asset,
tile_atlas_asset,
tile_assets: snapshot
@@ -166,19 +202,27 @@ fn map_jump_hop_work_snapshot(
.into_iter()
.map(map_tile_asset)
.collect(),
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
})
}
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
let theme_text = if snapshot.theme_text.trim().is_empty() {
snapshot.work_title.clone()
} else {
snapshot.theme_text.clone()
};
JumpHopDraftResponse {
template_id: snapshot.template_id,
template_name: snapshot.template_name,
profile_id: snapshot.profile_id,
theme_text,
work_title: snapshot.work_title,
work_description: snapshot.work_description,
theme_tags: snapshot.theme_tags,
difficulty: parse_difficulty(&snapshot.difficulty),
style_preset: parse_style_preset(&snapshot.style_preset),
default_character: Some(default_jump_hop_character()),
character_prompt: snapshot.character_prompt,
tile_prompt: snapshot.tile_prompt,
end_mood_prompt: snapshot.end_mood_prompt,
@@ -191,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe
.collect(),
path: snapshot.path.map(map_jump_hop_path),
cover_composite: snapshot.cover_composite,
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status),
}
}
@@ -211,10 +256,13 @@ fn map_character_asset(snapshot: JumpHopCharacterAssetSnapshot) -> JumpHopCharac
fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset {
JumpHopTileAsset {
tile_type: parse_tile_type(&snapshot.tile_type),
tile_id: snapshot.tile_id,
image_src: snapshot.image_src,
image_object_key: snapshot.image_object_key,
asset_object_id: snapshot.asset_object_id,
source_atlas_cell: snapshot.source_atlas_cell,
atlas_row: snapshot.atlas_row,
atlas_col: snapshot.atlas_col,
visual_width: snapshot.visual_width,
visual_height: snapshot.visual_height,
top_surface_radius: snapshot.top_surface_radius,
@@ -263,6 +311,8 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS
crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing,
},
current_platform_index: snapshot.current_platform_index,
successful_jump_count: snapshot.current_platform_index,
duration_ms: jump_hop_duration_ms(snapshot.started_at_ms, snapshot.finished_at_ms),
score: snapshot.score,
combo: snapshot.combo,
path: map_jump_hop_path(snapshot.path),
@@ -286,6 +336,34 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS
}
}
fn map_jump_hop_leaderboard_entry_snapshot(
snapshot: JumpHopLeaderboardEntrySnapshot,
) -> JumpHopLeaderboardEntry {
JumpHopLeaderboardEntry {
rank: snapshot.rank,
player_id: snapshot.player_id,
successful_jump_count: snapshot.successful_jump_count,
duration_ms: snapshot.duration_ms,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
fn default_jump_hop_character() -> JumpHopDefaultCharacter {
JumpHopDefaultCharacter {
character_id: "jump-hop-default-runner".to_string(),
display_name: "默认角色".to_string(),
model_kind: "builtin-three".to_string(),
body_color: "#f59e0b".to_string(),
accent_color: "#2563eb".to_string(),
}
}
fn jump_hop_duration_ms(started_at_ms: u64, finished_at_ms: Option<u64>) -> u64 {
finished_at_ms
.unwrap_or(started_at_ms)
.saturating_sub(started_at_ms)
}
fn parse_difficulty(value: &str) -> JumpHopDifficulty {
match value {
"easy" => JumpHopDifficulty::Easy,

View File

@@ -296,26 +296,30 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
event_banners_json: header.event_banners_json,
creation_types: creation_types
.into_iter()
.map(|item| module_runtime::CreationEntryTypeSnapshot {
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
image_src: item.image_src,
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: creation_entry_text_or_default(
item.category_id,
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
),
category_label: creation_entry_text_or_default(
item.category_label,
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
),
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: item.unified_creation_spec_json,
.map(|item| {
normalize_creation_entry_type_snapshot(
module_runtime::CreationEntryTypeSnapshot {
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
image_src: item.image_src,
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: creation_entry_text_or_default(
item.category_id,
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
),
category_label: creation_entry_text_or_default(
item.category_label,
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
),
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: item.unified_creation_spec_json,
},
)
})
.collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
@@ -353,20 +357,22 @@ fn map_creation_entry_config_snapshot(
creation_types: snapshot
.creation_types
.into_iter()
.map(|item| module_runtime::CreationEntryTypeSnapshot {
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
image_src: item.image_src,
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: item.category_id,
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
unified_creation_spec_json: item.unified_creation_spec_json,
.map(|item| {
normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot {
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
image_src: item.image_src,
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: item.category_id,
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
unified_creation_spec_json: item.unified_creation_spec_json,
})
})
.collect(),
updated_at_micros: snapshot.updated_at_micros,
@@ -380,6 +386,150 @@ fn creation_entry_text_or_default(value: Option<String>, default_value: &str) ->
.unwrap_or_else(|| default_value.to_string())
}
fn normalize_creation_entry_type_snapshot(
item: module_runtime::CreationEntryTypeSnapshot,
) -> module_runtime::CreationEntryTypeSnapshot {
// 中文注释:旧库里残留的跳一跳系统默认入口行仍会从订阅缓存命中,这里统一做读模型纠偏,
// 这样无论走订阅缓存还是 procedure 回退,创作页都只会看到新的跳一跳入口口径。
if item.id == "jump-hop"
&& item.title == "跳一跳"
&& item.subtitle == "俯视角跳跃闯关"
&& item.badge == "可创建"
&& item.image_src == "/creation-type-references/puzzle.webp"
&& item.visible
&& item.open
&& item.sort_order == 45
{
return module_runtime::CreationEntryTypeSnapshot {
subtitle: "主题驱动平台跳跃".to_string(),
image_src: "/creation-type-references/jump-hop.webp".to_string(),
..item
};
}
item
}
#[cfg(test)]
mod tests {
use super::*;
use spacetimedb_sdk::Timestamp;
fn build_creation_entry_header() -> CreationEntryConfig {
CreationEntryConfig {
config_id: "creation-entry-config".to_string(),
start_title: "新建作品".to_string(),
start_description: "选择模板后进入对应的创作表单。".to_string(),
start_idle_badge: "模板 Tab".to_string(),
start_busy_badge: "正在开启".to_string(),
modal_title: "选择创作类型".to_string(),
modal_description: "先选玩法类型,再进入对应创作工作台。".to_string(),
updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000),
event_title: None,
event_description: None,
event_cover_image_src: None,
event_prize_pool_mud_points: 0,
event_starts_at_text: None,
event_ends_at_text: None,
event_banners_json: None,
}
}
fn build_old_jump_hop_row() -> CreationEntryTypeConfig {
CreationEntryTypeConfig {
id: "jump-hop".to_string(),
title: "跳一跳".to_string(),
subtitle: "俯视角跳跃闯关".to_string(),
badge: "可创建".to_string(),
image_src: "/creation-type-references/puzzle.webp".to_string(),
visible: true,
open: true,
sort_order: 45,
updated_at: Timestamp::from_micros_since_unix_epoch(2_000_000),
category_id: Some("recommended".to_string()),
category_label: Some("热门推荐".to_string()),
category_sort_order: 20,
unified_creation_spec_json: None,
}
}
#[test]
fn build_creation_entry_config_record_from_rows_normalizes_old_jump_hop_row() {
let record = build_creation_entry_config_record_from_rows(
build_creation_entry_header(),
vec![build_old_jump_hop_row()],
);
let jump_hop = record
.creation_types
.iter()
.find(|item| item.id == "jump-hop")
.expect("should contain jump-hop");
assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
assert_eq!(
jump_hop.image_src,
"/creation-type-references/jump-hop.webp"
);
}
#[test]
fn map_creation_entry_config_snapshot_normalizes_old_jump_hop_snapshot() {
let record = map_creation_entry_config_snapshot(CreationEntryConfigSnapshot {
config_id: "creation-entry-config".to_string(),
start_card: CreationEntryStartCardSnapshot {
title: "新建作品".to_string(),
description: "选择模板后进入对应的创作表单。".to_string(),
idle_badge: "模板 Tab".to_string(),
busy_badge: "正在开启".to_string(),
},
type_modal: CreationEntryTypeModalSnapshot {
title: "选择创作类型".to_string(),
description: "先选玩法类型,再进入对应创作工作台。".to_string(),
},
event_banner: CreationEntryEventBannerSnapshot {
title: "主题创作赛".to_string(),
description: "用温暖的色彩,捏出秋天的故事。".to_string(),
cover_image_src: "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png".to_string(),
prize_pool_mud_points: 58_000,
starts_at_text: "2024.10.20 10:00".to_string(),
ends_at_text: "2024.11.20 23:59".to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: None,
creation_types: vec![CreationEntryTypeSnapshot {
id: "jump-hop".to_string(),
title: "跳一跳".to_string(),
subtitle: "俯视角跳跃闯关".to_string(),
badge: "可创建".to_string(),
image_src: "/creation-type-references/puzzle.webp".to_string(),
visible: true,
open: true,
sort_order: 45,
category_id: "recommended".to_string(),
category_label: "热门推荐".to_string(),
category_sort_order: 20,
updated_at_micros: 2_000_000,
unified_creation_spec_json: None,
}],
updated_at_micros: 1_000_000,
});
let jump_hop = record
.creation_types
.iter()
.find(|item| item.id == "jump-hop")
.expect("should contain jump-hop");
assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
assert_eq!(
jump_hop.image_src,
"/creation-type-references/jump-hop.webp"
);
}
}
pub(crate) fn map_runtime_setting_procedure_result(
result: RuntimeSettingProcedureResult,
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {

View File

@@ -370,6 +370,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;
@@ -438,6 +439,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;
@@ -1417,6 +1423,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;
@@ -1485,6 +1492,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;
@@ -2416,6 +2428,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>,
@@ -2630,6 +2643,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)?,
),
@@ -3059,6 +3075,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",
@@ -3544,6 +3566,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)?),
@@ -3887,6 +3912,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)?),
@@ -4146,6 +4174,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>,
@@ -4438,6 +4467,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,
@@ -5460,6 +5494,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);
@@ -5572,6 +5607,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",

View File

@@ -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,
);
}
}

View File

@@ -25,6 +25,7 @@ pub struct JumpHopDraftCompileInput {
pub tile_atlas_asset_json: Option<String>,
pub tile_assets_json: Option<String>,
pub cover_composite: Option<String>,
pub back_button_asset_json: Option<String>,
pub generation_status: Option<String>,
pub compiled_at_micros: i64,
}

View File

@@ -14,6 +14,7 @@ pub struct JumpHopDraftSnapshot {
pub template_id: String,
pub template_name: String,
pub profile_id: Option<String>,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -27,6 +28,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<JumpHopPath>,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String,
}

View File

@@ -12,6 +12,7 @@ pub struct JumpHopGalleryCardViewRow {
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,
pub theme_tags: Vec<String>,
@@ -38,6 +39,7 @@ pub struct JumpHopGalleryCardViewRowCols {
pub profile_id: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub owner_user_id: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub author_display_name: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub theme_text: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub work_title: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub work_description: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub theme_tags: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, Vec<String>>,
@@ -63,6 +65,7 @@ impl __sdk::__query_builder::HasCols for JumpHopGalleryCardViewRow {
table_name,
"author_display_name",
),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
work_title: __sdk::__query_builder::Col::new(table_name, "work_title"),
work_description: __sdk::__query_builder::Col::new(table_name, "work_description"),
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),

View File

@@ -16,6 +16,7 @@ pub struct JumpHopGalleryViewRow {
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -51,6 +52,7 @@ pub struct JumpHopGalleryViewRowCols {
pub owner_user_id: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub source_session_id: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub author_display_name: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub theme_text: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub work_title: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub work_description: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub theme_tags: __sdk::__query_builder::Col<JumpHopGalleryViewRow, Vec<String>>,
@@ -88,6 +90,7 @@ impl __sdk::__query_builder::HasCols for JumpHopGalleryViewRow {
table_name,
"author_display_name",
),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
work_title: __sdk::__query_builder::Col::new(table_name, "work_title"),
work_description: __sdk::__query_builder::Col::new(table_name, "work_description"),
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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")
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -19,6 +19,7 @@ pub struct JumpHopRuntimeRunRow {
pub snapshot_json: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
pub runtime_mode: Option<String>,
}
impl __sdk::InModule for JumpHopRuntimeRunRow {
@@ -41,6 +42,7 @@ pub struct JumpHopRuntimeRunRowCols {
pub snapshot_json: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, String>,
pub created_at: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, __sdk::Timestamp>,
pub runtime_mode: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, Option<String>>,
}
impl __sdk::__query_builder::HasCols for JumpHopRuntimeRunRow {
@@ -62,6 +64,7 @@ impl __sdk::__query_builder::HasCols for JumpHopRuntimeRunRow {
snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
runtime_mode: __sdk::__query_builder::Col::new(table_name, "runtime_mode"),
}
}
}

View File

@@ -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,

View File

@@ -32,6 +32,8 @@ pub struct JumpHopWorkProfileRow {
pub updated_at: __sdk::Timestamp,
pub published_at: Option<__sdk::Timestamp>,
pub visible: bool,
pub theme_text: Option<String>,
pub back_button_asset_json: Option<String>,
}
impl __sdk::InModule for JumpHopWorkProfileRow {
@@ -67,6 +69,8 @@ pub struct JumpHopWorkProfileRowCols {
pub updated_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, __sdk::Timestamp>,
pub published_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<__sdk::Timestamp>>,
pub visible: __sdk::__query_builder::Col<JumpHopWorkProfileRow, bool>,
pub theme_text: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<String>>,
pub back_button_asset_json: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<String>>,
}
impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow {
@@ -107,6 +111,11 @@ impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow {
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
published_at: __sdk::__query_builder::Col::new(table_name, "published_at"),
visible: __sdk::__query_builder::Col::new(table_name, "visible"),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
back_button_asset_json: __sdk::__query_builder::Col::new(
table_name,
"back_button_asset_json",
),
}
}
}

View File

@@ -16,6 +16,7 @@ pub struct JumpHopWorkSnapshot {
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -30,6 +31,7 @@ pub struct JumpHopWorkSnapshot {
pub path: JumpHopPath,
pub cover_image_src: String,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,

View File

@@ -52,6 +52,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
profile_id: row.profile_id,
owner_user_id: row.owner_user_id,
author_display_name: row.author_display_name,
theme_text: row.theme_text,
work_title: row.work_title,
work_description: row.work_description,
theme_tags: row.theme_tags,
@@ -74,6 +75,7 @@ pub struct JumpHopGalleryViewRow {
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -103,6 +105,7 @@ pub struct JumpHopGalleryCardViewRow {
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,
pub theme_tags: Vec<String>,
@@ -264,6 +267,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,
@@ -291,6 +317,7 @@ fn create_jump_hop_agent_session_tx(
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
theme_text: config.theme_text.clone(),
work_title: input.work_title.clone(),
work_description: input.work_description.clone(),
theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?,
@@ -304,6 +331,7 @@ fn create_jump_hop_agent_session_tx(
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(),
};
ctx.db
@@ -356,6 +384,7 @@ fn compile_jump_hop_draft_tx(
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: Some(input.profile_id.clone()),
theme_text: clean_string(&config.theme_text, &input.work_title),
work_title: clean_string(&input.work_title, "跳一跳作品"),
work_description: input.work_description.trim().to_string(),
theme_tags: tags.clone(),
@@ -382,6 +411,11 @@ fn compile_jump_hop_draft_tx(
.unwrap_or_default(),
path: Some(path.clone()),
cover_composite: input.cover_composite.as_deref().and_then(clean_optional),
back_button_asset: input
.back_button_asset_json
.as_deref()
.map(parse_json)
.transpose()?,
generation_status: input
.generation_status
.clone()
@@ -416,12 +450,14 @@ fn compile_jump_hop_draft_tx(
path_json: to_json_string(&path),
cover_image_src: draft.cover_composite.clone().unwrap_or_default(),
cover_composite: draft.cover_composite.clone().unwrap_or_default(),
back_button_asset_json: draft.back_button_asset.as_ref().map(to_json_string),
generation_status: draft.generation_status.clone(),
publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: compiled_at,
published_at: None,
visible: true,
theme_text: Some(draft.theme_text.clone()),
};
upsert_work(ctx, row);
replace_session(
@@ -612,6 +648,15 @@ 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_DRAFT && work.owner_user_id != input.owner_user_id {
return Err("jump_hop draft runtime 只能由作品所有者启动".to_string());
}
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(),
@@ -622,8 +667,10 @@ 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);
upsert_run(ctx, &snapshot, input.started_at_ms, runtime_mode);
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,
@@ -651,10 +698,22 @@ 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
&& normalize_runtime_mode(row.runtime_mode.as_deref().unwrap_or_default())
== JUMP_HOP_RUNTIME_MODE_PUBLISHED
{
upsert_jump_hop_leaderboard_entry(ctx, &next, input.jumped_at_ms);
}
insert_event(
ctx,
input.client_event_id,
@@ -671,6 +730,50 @@ 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 work = find_work(ctx, &input.profile_id)?;
if work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED {
return Err("jump_hop leaderboard 只开放已发布作品".to_string());
}
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,
@@ -684,7 +787,8 @@ fn restart_jump_hop_run_tx(
)
.map_err(|error| error.to_string())?;
let next = domain_next;
upsert_run(ctx, &next, input.restarted_at_ms);
let runtime_mode = normalize_runtime_mode(source.runtime_mode.as_deref().unwrap_or_default());
upsert_run(ctx, &next, input.restarted_at_ms, runtime_mode);
insert_event(
ctx,
input.client_action_id,
@@ -706,6 +810,7 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
owner_user_id: work.owner_user_id,
source_session_id: work.source_session_id,
author_display_name: work.author_display_name,
theme_text: work.theme_text,
work_title: work.work_title,
work_description: work.work_description,
theme_tags: work.theme_tags,
@@ -771,12 +876,18 @@ fn build_session_snapshot(
fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapshot, String> {
let path = parse_json(&row.path_json)?;
let theme_text = row
.theme_text
.as_deref()
.and_then(clean_optional)
.unwrap_or_else(|| row.work_title.trim().to_string());
Ok(JumpHopWorkSnapshot {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
theme_text,
work_title: row.work_title.clone(),
work_description: row.work_description.clone(),
theme_tags: parse_tags(&row.theme_tags_json)?,
@@ -795,6 +906,12 @@ fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapsho
path,
cover_image_src: row.cover_image_src.clone(),
cover_composite: clean_optional(&row.cover_composite),
back_button_asset: row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
publication_status: row.publication_status.clone(),
publish_ready: is_publish_ready(row),
play_count: row.play_count,
@@ -821,7 +938,11 @@ fn sync_session_from_work_update(
};
let mut config = parse_config(&session.config_json)?;
config.theme_text = work.work_title.clone();
config.theme_text = work
.theme_text
.as_deref()
.and_then(clean_optional)
.unwrap_or_else(|| work.work_title.trim().to_string());
config.difficulty = work.difficulty.clone();
config.style_preset = work.style_preset.clone();
config.character_prompt = work.character_prompt.clone();
@@ -832,6 +953,7 @@ fn sync_session_from_work_update(
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: Some(work.profile_id.clone()),
theme_text: config.theme_text.clone(),
work_title: work.work_title.clone(),
work_description: work.work_description.clone(),
theme_tags: parse_tags(&work.theme_tags_json)?,
@@ -849,6 +971,12 @@ fn sync_session_from_work_update(
tile_assets: parse_json_or_default(&work.tile_assets_json),
path: Some(parse_json(&work.path_json)?),
cover_composite: clean_optional(&work.cover_composite),
back_button_asset: work
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
generation_status: work.generation_status.clone(),
};
@@ -945,7 +1073,12 @@ fn replace_session(
ctx.db.jump_hop_agent_session().insert(next);
}
fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms: i64) {
fn upsert_run(
ctx: &ReducerContext,
snapshot: &JumpHopRunSnapshot,
updated_at_ms: i64,
runtime_mode: &str,
) {
if let Some(old) = ctx
.db
.jump_hop_runtime_run()
@@ -955,9 +1088,12 @@ fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms
ctx.db.jump_hop_runtime_run().delete(old);
}
let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000));
ctx.db
.jump_hop_runtime_run()
.insert(run_row_from_snapshot(snapshot, created_at, created_at));
ctx.db.jump_hop_runtime_run().insert(run_row_from_snapshot(
snapshot,
created_at,
created_at,
runtime_mode,
));
}
fn replace_run(
@@ -971,6 +1107,7 @@ fn replace_run(
snapshot,
old.created_at,
Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)),
normalize_runtime_mode(old.runtime_mode.as_deref().unwrap_or_default()),
));
}
@@ -978,6 +1115,7 @@ fn run_row_from_snapshot(
snapshot: &JumpHopRunSnapshot,
created_at: Timestamp,
updated_at: Timestamp,
runtime_mode: &str,
) -> JumpHopRuntimeRunRow {
JumpHopRuntimeRunRow {
run_id: snapshot.run_id.clone(),
@@ -995,6 +1133,7 @@ fn run_row_from_snapshot(
snapshot_json: to_json_string(snapshot),
created_at,
updated_at,
runtime_mode: Some(normalize_runtime_mode(runtime_mode).to_string()),
}
}
@@ -1040,12 +1179,129 @@ 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()
&& row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.is_some()
}
fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot {
@@ -1054,8 +1310,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}主题的正面30度视角主题物体图集物体本身作为跳跃落点"),
end_mood_prompt: String::new(),
}
}
@@ -1235,6 +1491,8 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow {
updated_at: row.updated_at,
published_at: row.published_at,
visible: row.visible,
theme_text: row.theme_text.clone(),
back_button_asset_json: row.back_button_asset_json.clone(),
}
}
@@ -1252,6 +1510,68 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
snapshot_json: row.snapshot_json.clone(),
created_at: row.created_at,
updated_at: row.updated_at,
runtime_mode: row.runtime_mode.clone(),
}
}
#[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
));
}
}

View File

@@ -56,6 +56,12 @@ pub struct JumpHopWorkProfileRow {
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
// 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。
#[default(None::<String>)]
pub(crate) theme_text: Option<String>,
// 跳一跳左上角真实可点击返回按钮的独立透明资产快照;旧行为空时运行态使用样式兜底。
#[default(None::<String>)]
pub(crate) back_button_asset_json: Option<String>,
}
#[spacetimedb::table(
@@ -77,6 +83,9 @@ pub struct JumpHopRuntimeRunRow {
pub(crate) snapshot_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
// draft / published用于隔离试玩统计和公开排行榜旧行按 published 兜底。
#[default(None::<String>)]
pub(crate) runtime_mode: Option<String>,
}
#[spacetimedb::table(
@@ -94,3 +103,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,
}

View File

@@ -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 {
@@ -54,6 +56,7 @@ pub struct JumpHopDraftCompileInput {
pub tile_atlas_asset_json: Option<String>,
pub tile_assets_json: Option<String>,
pub cover_composite: Option<String>,
pub back_button_asset_json: Option<String>,
pub generation_status: Option<String>,
pub compiled_at_micros: i64,
}
@@ -102,6 +105,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,
}
@@ -112,11 +116,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,
}
@@ -158,6 +164,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 {
@@ -187,10 +218,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,
@@ -203,6 +240,8 @@ pub struct JumpHopDraftSnapshot {
pub template_id: String,
pub template_name: String,
pub profile_id: Option<String>,
#[serde(default)]
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -216,6 +255,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<module_jump_hop::JumpHopPath>,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String,
}
@@ -244,6 +284,7 @@ pub struct JumpHopWorkSnapshot {
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -258,6 +299,7 @@ pub struct JumpHopWorkSnapshot {
pub path: module_jump_hop::JumpHopPath,
pub cover_image_src: String,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,

View File

@@ -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,
@@ -1328,6 +1330,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
if table_name == "jump_hop_work_profile" {
// 中文注释:跳一跳主题返回按钮资产晚于首版作品表加入,旧迁移包按未生成按钮兼容。
object
.entry("back_button_asset_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
}
if table_name == "match_3_d_work_profile" || table_name == "match3d_work_profile" {

View File

@@ -338,6 +338,7 @@ fn map_custom_world_detail_entry(row: CustomWorldProfileSnapshot) -> PublicWorkD
fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalleryEntry {
let subtitle = jump_hop_difficulty_label(&row.difficulty).to_string();
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
let theme_text = row.theme_text.clone();
PublicWorkGalleryEntry {
source_type: "jump-hop".to_string(),
@@ -352,7 +353,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle
summary_text: row.work_description,
cover_image_src: empty_string_to_option(row.cover_image_src),
cover_asset_id: None,
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]),
theme_tags: fallback_tags(row.theme_tags, &[theme_text.as_str(), "跳一跳"]),
play_count: row.play_count,
remix_count: 0,
like_count: 0,
@@ -363,6 +364,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle
}
fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntry {
let theme_text = row.theme_text.clone();
let entry = PublicWorkGalleryEntry {
source_type: "jump-hop".to_string(),
work_id: row.work_id,
@@ -376,7 +378,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr
summary_text: row.work_description,
cover_image_src: empty_string_to_option(row.cover_image_src),
cover_asset_id: None,
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]),
theme_tags: fallback_tags(row.theme_tags, &[theme_text.as_str(), "跳一跳"]),
play_count: row.play_count,
remix_count: 0,
like_count: 0,
@@ -388,6 +390,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr
"sourceType": "jump-hop",
"difficulty": row.difficulty,
"stylePreset": row.style_preset,
"themeText": theme_text,
"tileAssetCount": row.tile_assets.len(),
"platformCount": row.path.platforms.len(),
"generationStatus": row.generation_status,

View File

@@ -296,6 +296,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) {
@@ -447,6 +448,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()