Enforce Genarrative play-type SOP and update docs

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.
This commit is contained in:
2026-05-20 12:12:00 +08:00
parent f370539a6f
commit 3931442249
123 changed files with 15514 additions and 3419 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "module-jump-hop"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -0,0 +1,395 @@
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());
}
}

View File

@@ -0,0 +1,18 @@
use shared_kernel::normalize_required_string;
use crate::JumpHopDifficulty;
pub fn parse_jump_hop_difficulty(value: &str) -> JumpHopDifficulty {
match value.trim().to_ascii_lowercase().as_str() {
"easy" | "轻松" => JumpHopDifficulty::Easy,
"advanced" | "进阶" => JumpHopDifficulty::Advanced,
"challenge" | "挑战" => JumpHopDifficulty::Challenge,
_ => JumpHopDifficulty::Standard,
}
}
pub fn normalize_jump_hop_seed(seed: &str, fallback: &str) -> String {
normalize_required_string(seed)
.or_else(|| normalize_required_string(fallback))
.unwrap_or_else(|| "jump-hop".to_string())
}

View File

@@ -0,0 +1,151 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const JUMP_HOP_SESSION_ID_PREFIX: &str = "jump-hop-session-";
pub const JUMP_HOP_PROFILE_ID_PREFIX: &str = "jump-hop-profile-";
pub const JUMP_HOP_WORK_ID_PREFIX: &str = "jump-hop-work-";
pub const JUMP_HOP_RUN_ID_PREFIX: &str = "jump-hop-run-";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum JumpHopDifficulty {
Easy,
Standard,
Advanced,
Challenge,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum JumpHopTileType {
Start,
Normal,
Target,
Finish,
Bonus,
Accent,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum JumpHopRunStatus {
Playing,
Failed,
Cleared,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum JumpHopJumpResultKind {
Miss,
Hit,
Perfect,
Finish,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JumpHopScoring {
pub charge_to_distance_ratio: f32,
pub max_charge_ms: u32,
pub hit_bonus: u32,
pub perfect_bonus: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JumpHopPlatform {
pub platform_id: String,
pub tile_type: JumpHopTileType,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub landing_radius: f32,
pub perfect_radius: f32,
pub score_value: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JumpHopPath {
pub seed: String,
pub difficulty: JumpHopDifficulty,
pub platforms: Vec<JumpHopPlatform>,
pub finish_index: u32,
pub camera_preset: String,
pub scoring: JumpHopScoring,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JumpHopLastJump {
pub charge_ms: u32,
pub jump_distance: f32,
pub target_platform_index: u32,
pub landed_x: f32,
pub landed_y: f32,
pub result: JumpHopJumpResultKind,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JumpHopRunSnapshot {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: JumpHopRunStatus,
pub current_platform_index: u32,
pub score: u32,
pub combo: u32,
pub last_jump: Option<JumpHopLastJump>,
pub started_at_ms: u64,
pub finished_at_ms: Option<u64>,
pub path: JumpHopPath,
}
impl JumpHopDifficulty {
pub fn as_str(self) -> &'static str {
match self {
Self::Easy => "easy",
Self::Standard => "standard",
Self::Advanced => "advanced",
Self::Challenge => "challenge",
}
}
}
impl JumpHopTileType {
pub fn as_str(self) -> &'static str {
match self {
Self::Start => "start",
Self::Normal => "normal",
Self::Target => "target",
Self::Finish => "finish",
Self::Bonus => "bonus",
Self::Accent => "accent",
}
}
}
impl JumpHopRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Playing => "playing",
Self::Failed => "failed",
Self::Cleared => "cleared",
}
}
}
impl JumpHopJumpResultKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Miss => "miss",
Self::Hit => "hit",
Self::Perfect => "perfect",
Self::Finish => "finish",
}
}
}

View File

@@ -0,0 +1,27 @@
use std::fmt::{self, Display};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JumpHopError {
MissingRunId,
MissingProfileId,
MissingOwnerUserId,
EmptyPath,
RunNotPlaying,
NoNextPlatform,
}
impl Display for JumpHopError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingRunId => "缺少 runId",
Self::MissingProfileId => "缺少 profileId",
Self::MissingOwnerUserId => "owner_user_id 缺失",
Self::EmptyPath => "跳一跳路径为空",
Self::RunNotPlaying => "当前运行态不是 playing",
Self::NoNextPlatform => "没有下一块平台",
};
write!(f, "{message}")
}
}
impl std::error::Error for JumpHopError {}

View File

@@ -0,0 +1,23 @@
//! 跳一跳领域事件。
//!
//! 事件只表达已发生的领域事实,是否持久化、投影或广播由 SpacetimeDB adapter 决定。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JumpHopDomainEvent {
DraftCompiled {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
WorkPublished {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RunSettled {
run_id: String,
owner_user_id: String,
status: String,
occurred_at_micros: i64,
},
}

View File

@@ -0,0 +1,11 @@
mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;