Merge remote-tracking branch 'origin/codex/tiaoyitiao' into codex/tiaoyitiao

# Conflicts:
#	docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md
#	server-rs/crates/spacetime-client/src/jump_hop.rs
#	server-rs/crates/spacetime-client/src/mapper/runtime.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	src/components/jump-hop-result/JumpHopResultView.test.tsx
#	src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx
#	src/components/unified-creation/workspaces/JumpHopWorkspace.test.tsx
This commit is contained in:
2026-06-04 11:34:31 +08:00
52 changed files with 6752 additions and 2346 deletions

View File

@@ -245,6 +245,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,
@@ -543,6 +566,12 @@ 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_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(),
@@ -554,7 +583,9 @@ 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);
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,
@@ -582,10 +613,19 @@ 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 {
upsert_jump_hop_leaderboard_entry(ctx, &next, input.jumped_at_ms);
}
insert_event(
ctx,
input.client_event_id,
@@ -602,6 +642,47 @@ 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 _ = find_work(ctx, &input.profile_id)?;
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,
@@ -971,9 +1052,121 @@ 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()
@@ -985,8 +1178,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}主题的俯视角清爽游戏化立体感平台素材"),
end_mood_prompt: String::new(),
}
}
@@ -1185,3 +1378,64 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
updated_at: row.updated_at,
}
}
#[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

@@ -94,3 +94,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 {
@@ -96,6 +98,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,
}
@@ -106,11 +109,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,
}
@@ -152,6 +157,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 {
@@ -181,10 +211,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,

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,

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()