use shared_kernel::normalize_required_string; use crate::{ JumpHopDifficulty, JumpHopError, JumpHopJumpResultKind, JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType, }; const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0; const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.004; // 中文注释:命中区必须与视觉顶面一致,禁止再做隐藏收缩。 const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 1.0; const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 1.0; pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { let config = difficulty_config(difficulty); let platform_count = 8usize; let platforms = build_platforms_until(seed, difficulty, platform_count); JumpHopPath { seed: seed.trim().to_string(), difficulty, finish_index: u32::MAX, platforms, camera_preset: "portrait-isometric-9x16".to_string(), scoring: JumpHopScoring { charge_to_distance_ratio: config.charge_to_distance_ratio, max_charge_ms: config.max_charge_ms, hit_bonus: 20, perfect_bonus: 60, }, } } pub fn start_run( run_id: String, owner_user_id: String, profile_id: String, path: JumpHopPath, started_at_ms: u64, ) -> Result { let run_id = normalize_required_string(run_id).ok_or(JumpHopError::MissingRunId)?; let owner_user_id = normalize_required_string(owner_user_id).ok_or(JumpHopError::MissingOwnerUserId)?; let profile_id = normalize_required_string(profile_id).ok_or(JumpHopError::MissingProfileId)?; if path.platforms.is_empty() { return Err(JumpHopError::EmptyPath); } let path = normalize_jump_hop_path_platform_size(path); Ok(JumpHopRunSnapshot { run_id, profile_id, owner_user_id, status: JumpHopRunStatus::Playing, current_platform_index: 0, score: 0, combo: 0, last_jump: None, started_at_ms, finished_at_ms: None, path, }) } pub fn apply_jump( run: &JumpHopRunSnapshot, drag_distance: f32, _drag_vector_x: Option, _drag_vector_y: Option, jumped_at_ms: u64, ) -> Result { if run.status != JumpHopRunStatus::Playing { return Err(JumpHopError::RunNotPlaying); } 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 = path .platforms .get(next_index) .ok_or(JumpHopError::NoNextPlatform)?; 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 (origin_x, origin_y) = current_jump_origin(run, current); let vector_x = target.x - origin_x; let vector_y = target.y - origin_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 landed_x = origin_x + unit_x * jump_distance; let landed_y = origin_y + unit_y * jump_distance; let landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y); let mut next = run.clone(); next.path = path; let result = if landed_on_target { JumpHopJumpResultKind::Hit } else { JumpHopJumpResultKind::Miss }; next.last_jump = Some(JumpHopLastJump { charge_ms: capped_drag_distance.round() as u32, jump_distance, target_platform_index: next_index as u32, landed_x, landed_y, result, }); if result == JumpHopJumpResultKind::Miss { next.status = JumpHopRunStatus::Failed; next.combo = 0; next.finished_at_ms = Some(jumped_at_ms); return Ok(next); } next.current_platform_index = next_index as u32; next.combo = 0; next.score = next.current_platform_index; Ok(next) } fn current_jump_origin(run: &JumpHopRunSnapshot, current: &JumpHopPlatform) -> (f32, f32) { if let Some(last_jump) = &run.last_jump { let landed_on_current = last_jump.target_platform_index == run.current_platform_index; let is_successful = last_jump.result != JumpHopJumpResultKind::Miss; if landed_on_current && is_successful && last_jump.landed_x.is_finite() && last_jump.landed_y.is_finite() { return (last_jump.landed_x, last_jump.landed_y); } } (current.x, current.y) } fn is_landing_inside_platform_footprint( platform: &JumpHopPlatform, landed_x: f32, landed_y: f32, ) -> bool { let half_width = (platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO).max(0.0); let half_height = (platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO).max(0.0); if half_width <= f32::EPSILON || half_height <= f32::EPSILON { return false; } let error_x = landed_x - platform.x; let error_y = landed_y - platform.y; error_x.abs() / half_width + error_y.abs() / half_height <= 1.0 + f32::EPSILON } pub fn restart_run( run: &JumpHopRunSnapshot, next_run_id: String, restarted_at_ms: u64, ) -> Result { start_run( next_run_id, run.owner_user_id.clone(), run.profile_id.clone(), run.path.clone(), restarted_at_ms, ) } 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_gap: f32, max_gap: f32, min_width: f32, max_width: f32, landing_radius_factor: f32, perfect_radius_factor: f32, charge_to_distance_ratio: f32, max_charge_ms: u32, } const JUMP_HOP_MIN_GAP_RATIO_OF_MAX: f32 = 0.55; fn build_platforms_until( seed: &str, difficulty: JumpHopDifficulty, required_count: usize, ) -> Vec { 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 direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; x += distance * 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 difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { match difficulty { JumpHopDifficulty::Easy => DifficultyConfig { min_gap: 1.45 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, 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: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 700, }, JumpHopDifficulty::Standard => DifficultyConfig { min_gap: 1.78 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, 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: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 780, }, JumpHopDifficulty::Advanced => DifficultyConfig { min_gap: 2.05 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, 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: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 860, }, JumpHopDifficulty::Challenge => DifficultyConfig { min_gap: 2.35 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, 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: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 950, }, } } struct DeterministicRng { state: u64, } impl DeterministicRng { fn new(seed: &str, salt: &str) -> Self { let mut state = 0xcbf2_9ce4_8422_2325u64; for byte in seed.bytes().chain(salt.bytes()) { state ^= u64::from(byte); state = state.wrapping_mul(0x1000_0000_01b3); } Self { state } } fn next_u32(&mut self) -> u32 { self.state = self .state .wrapping_mul(6_364_136_223_846_793_005) .wrapping_add(1); (self.state >> 32) as u32 } fn range_f32(&mut self, min: f32, max: f32) -> f32 { if max <= min { return min; } let unit = self.next_u32() as f32 / u32::MAX as f32; min + (max - min) * unit } } #[cfg(test)] mod tests { use crate::{ JumpHopDifficulty, JumpHopJumpResultKind, JumpHopPlatform, JumpHopRunStatus, JumpHopTileType, apply_jump, generate_jump_hop_path, restart_run, start_run, }; #[test] fn path_generation_is_seeded_and_uses_difficulty_ranges() { let first = generate_jump_hop_path("seed-a", JumpHopDifficulty::Standard); let second = generate_jump_hop_path("seed-a", JumpHopDifficulty::Standard); let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge); assert_eq!(first, second); 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.finish_index, u32::MAX); } #[test] fn generated_platforms_are_locked_to_positive_or_negative_45_degree_lanes() { for difficulty in [ JumpHopDifficulty::Easy, JumpHopDifficulty::Standard, JumpHopDifficulty::Advanced, JumpHopDifficulty::Challenge, ] { let path = generate_jump_hop_path("seed-45-degree", difficulty); for pair in path.platforms.windows(2) { let current = &pair[0]; let next = &pair[1]; let dx = next.x - current.x; let dy = next.y - current.y; assert!( dy > 0.0, "next platform should always move forward for {difficulty:?}", ); assert!( (dx.abs() - dy).abs() < 0.0001, "next platform should stay on a 45 degree lane for {difficulty:?}: dx={dx}, dy={dy}", ); } } } #[test] fn generated_platform_gaps_use_current_spacing_as_max_and_nonzero_min() { for difficulty in [ JumpHopDifficulty::Easy, JumpHopDifficulty::Standard, JumpHopDifficulty::Advanced, JumpHopDifficulty::Challenge, ] { let config = super::difficulty_config(difficulty); let path = generate_jump_hop_path("seed-random-gap-range", difficulty); for pair in path.platforms.windows(2) { let current = &pair[0]; let next = &pair[1]; let gap = next.y - current.y; assert!( gap >= config.max_gap * super::JUMP_HOP_MIN_GAP_RATIO_OF_MAX - 0.0001, "gap should keep a non-zero minimum for {difficulty:?}: gap={gap}, max={}", config.max_gap, ); assert!( gap <= config.max_gap + 0.0001, "gap should not exceed the current max spacing for {difficulty:?}: gap={gap}, max={}", config.max_gap, ); } } } #[test] fn generated_platform_centers_are_reachable_within_charge_window() { for difficulty in [ JumpHopDifficulty::Easy, JumpHopDifficulty::Standard, JumpHopDifficulty::Advanced, JumpHopDifficulty::Challenge, ] { let path = generate_jump_hop_path("seed-reachable-centers", difficulty); for pair in path.platforms.windows(2) { let current = &pair[0]; let next = &pair[1]; let distance = (next.x - current.x).hypot(next.y - current.y); let required_charge = distance / path.scoring.charge_to_distance_ratio; assert!( required_charge <= path.scoring.max_charge_ms as f32, "next platform center must be reachable for {difficulty:?}: required={required_charge}, max={}", path.scoring.max_charge_ms, ); } } } #[test] fn difficulty_charge_to_distance_ratio_is_reduced_for_long_press() { 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.004); assert_eq!(standard.scoring.charge_to_distance_ratio, 0.004); assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.004); assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.004); } #[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(), "user-1".to_string(), "profile-1".to_string(), path, 100, ) .expect("run should start"); let target = &run.path.platforms[1]; let distance = target.x.hypot(target.y); let target_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32; let hit = 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, 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, JumpHopJumpResultKind::Miss ); } #[test] fn jump_resolution_ignores_client_drag_direction_and_targets_next_center() { 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(999.0), Some(-999.0), 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 jump_resolution_falls_back_to_next_center_when_drag_direction_missing() { 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, None, None, 200).expect("jump should resolve"); let last_jump = result.last_jump.as_ref().expect("last jump should exist"); assert_eq!(result.status, JumpHopRunStatus::Playing); assert_eq!(last_jump.result, JumpHopJumpResultKind::Hit); assert_eq!(result.current_platform_index, 1); assert!((last_jump.landed_x - target.x).abs() < target.landing_radius); assert!((last_jump.landed_y - target.y).abs() < target.landing_radius); } #[test] fn jump_resolution_uses_previous_landing_point_as_next_origin() { let mut path = generate_jump_hop_path("seed-foot-origin", JumpHopDifficulty::Easy); path.platforms[0] = test_platform("p0", 0.0, 0.0, 2.0, 2.0); path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 2.0); path.platforms[2] = test_platform("p2", 2.0, 1.0, 2.0, 2.0); let run = start_run( "run-foot-origin".to_string(), "user-foot-origin".to_string(), "profile-foot-origin".to_string(), path, 100, ) .expect("run should start"); let first_charge = 0.9 / run.path.scoring.charge_to_distance_ratio; let first = apply_jump(&run, first_charge, None, None, 200).expect("first jump should resolve"); let first_jump = first.last_jump.as_ref().expect("first jump should exist"); assert_eq!(first.status, JumpHopRunStatus::Playing); assert_eq!(first.current_platform_index, 1); assert_eq!(first_jump.result, JumpHopJumpResultKind::Hit); assert!((first_jump.landed_x - 0.9).abs() < 0.0001); let target = &first.path.platforms[2]; let second_origin = first.last_jump.as_ref().expect("origin should exist"); let second_distance = (target.x - second_origin.landed_x).hypot(target.y - second_origin.landed_y); let second_charge = second_distance / first.path.scoring.charge_to_distance_ratio; let second = apply_jump(&first, second_charge, None, None, 300).expect("second jump should resolve"); let second_jump = second.last_jump.as_ref().expect("second jump should exist"); assert_eq!(second.status, JumpHopRunStatus::Playing); assert_eq!(second.current_platform_index, 2); assert_eq!(second_jump.result, JumpHopJumpResultKind::Hit); assert!((second_jump.landed_x - target.x).abs() < 0.0001); assert!((second_jump.landed_y - target.y).abs() < 0.0001); } #[test] fn jump_resolution_uses_visual_top_face_footprint_instead_of_landing_radius() { let mut path = generate_jump_hop_path("seed-footprint", JumpHopDifficulty::Easy); path.platforms[0] = test_platform("p0", 0.0, 0.0, 1.2, 1.0); path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 0.6); path.platforms[1].landing_radius = 10.0; path.scoring.max_charge_ms = 600; let run = start_run( "run-footprint".to_string(), "user-footprint".to_string(), "profile-footprint".to_string(), path, 100, ) .expect("run should start"); let edge_hit_charge = 1.99 / run.path.scoring.charge_to_distance_ratio; let edge_hit = apply_jump(&run, edge_hit_charge, None, None, 200).expect("jump should resolve"); let last_hit = edge_hit.last_jump.as_ref().expect("last jump should exist"); assert_eq!(edge_hit.status, JumpHopRunStatus::Playing); assert_eq!(last_hit.result, JumpHopJumpResultKind::Hit); assert!(last_hit.landed_x > 1.98); assert!(last_hit.landed_x <= 2.0); let outside_charge = 2.01 / run.path.scoring.charge_to_distance_ratio; let outside = apply_jump(&run, outside_charge, None, None, 200).expect("jump should resolve"); assert_eq!(outside.status, JumpHopRunStatus::Failed); assert_eq!( outside.last_jump.as_ref().unwrap().result, JumpHopJumpResultKind::Miss ); } #[test] fn top_face_footprint_rejects_rectangle_corners_outside_visible_top() { let platform = test_platform("p-corner", 0.0, 0.0, 2.0, 2.0); assert!(super::is_landing_inside_platform_footprint( &platform, 0.5, 0.5, )); assert!(!super::is_landing_inside_platform_footprint( &platform, 0.7, 0.5, )); assert!(super::is_landing_inside_platform_footprint( &test_platform("p-diagonal", 0.8, 1.2, 2.0, 2.0), 1.3, 1.6, )); assert!(!super::is_landing_inside_platform_footprint( &test_platform("p-diagonal-outside", 0.8, 1.2, 2.0, 2.0), 1.4, 1.8, )); assert!(super::is_landing_inside_platform_footprint( &test_platform("p-side", 0.8, 1.2, 2.0, 2.0), -0.19, 1.2, )); } #[test] fn restart_returns_to_first_platform_and_playing_state() { let path = generate_jump_hop_path("seed-c", 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"); run.status = JumpHopRunStatus::Failed; run.current_platform_index = 3; run.score = 300; run.combo = 2; run.finished_at_ms = Some(200); let restarted = restart_run(&run, "run-2".to_string(), 300).expect("run should restart"); assert_eq!(restarted.run_id, "run-2"); assert_eq!(restarted.status, JumpHopRunStatus::Playing); assert_eq!(restarted.current_platform_index, 0); assert_eq!(restarted.score, 0); assert_eq!(restarted.combo, 0); assert!(restarted.last_jump.is_none()); 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()); } fn test_platform(id: &str, x: f32, y: f32, width: f32, height: f32) -> JumpHopPlatform { JumpHopPlatform { platform_id: id.to_string(), tile_type: JumpHopTileType::Normal, x, y, width, height, landing_radius: 0.2, perfect_radius: 0.1, score_value: 1, } } }