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:
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user