use shared_kernel::normalize_required_string; use crate::{ JumpHopDifficulty, JumpHopError, JumpHopJumpResultKind, JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType, }; 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; } } JumpHopPath { seed: seed.trim().to_string(), difficulty, finish_index: platform_count.saturating_sub(1) as u32, 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); } 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, charge_ms: u32, 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 current = run .path .platforms .get(current_index) .ok_or(JumpHopError::EmptyPath)?; let target = run .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 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 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 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 } } else { JumpHopJumpResultKind::Miss }; next.last_jump = Some(JumpHopLastJump { charge_ms: capped_charge, 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 = 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); } Ok(next) } 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, ) } struct DifficultyConfig { min_platforms: u32, max_platforms: u32, 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, } 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, 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, 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, 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, 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_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; } let unit = self.next_u32() as f32 / u32::MAX as f32; min + (max - min) * unit } } #[cfg(test)] mod tests { use crate::{ JumpHopDifficulty, JumpHopJumpResultKind, JumpHopRunStatus, 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!((16..=18).contains(&first.platforms.len())); assert!((26..=32).contains(&challenge.platforms.len())); assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start"); assert_eq!(first.platforms.last().unwrap().tile_type.as_str(), "finish"); } #[test] fn jump_resolution_distinguishes_perfect_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 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 hit = apply_jump(&run, perfect_charge.saturating_add(80), 200) .expect("jump should resolve"); assert_eq!( hit.last_jump.as_ref().unwrap().result, JumpHopJumpResultKind::Hit ); let miss = apply_jump(&run, perfect_charge.saturating_add(900), 200) .expect("jump should resolve"); assert_eq!(miss.status, JumpHopRunStatus::Failed); assert_eq!( miss.last_jump.as_ref().unwrap().result, JumpHopJumpResultKind::Miss ); } #[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()); } }