Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
396 lines
13 KiB
Rust
396 lines
13 KiB
Rust
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<JumpHopRunSnapshot, JumpHopError> {
|
|
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<JumpHopRunSnapshot, JumpHopError> {
|
|
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<JumpHopRunSnapshot, JumpHopError> {
|
|
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());
|
|
}
|
|
}
|