Merge remote-tracking branch 'origin/master' into dev-jenken

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
#	server-rs/crates/api-server/src/modules/jump_hop.rs
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/jump-hop/jumpHopClient.test.ts
This commit is contained in:
2026-06-05 23:59:40 +08:00
67 changed files with 8713 additions and 2537 deletions

View File

@@ -52,6 +52,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
profile_id: row.profile_id,
owner_user_id: row.owner_user_id,
author_display_name: row.author_display_name,
theme_text: row.theme_text,
work_title: row.work_title,
work_description: row.work_description,
theme_tags: row.theme_tags,
@@ -74,6 +75,7 @@ pub struct JumpHopGalleryViewRow {
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -103,6 +105,7 @@ pub struct JumpHopGalleryCardViewRow {
pub profile_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -264,6 +267,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,
@@ -291,6 +317,7 @@ fn create_jump_hop_agent_session_tx(
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
theme_text: config.theme_text.clone(),
work_title: input.work_title.clone(),
work_description: input.work_description.clone(),
theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?,
@@ -304,6 +331,7 @@ fn create_jump_hop_agent_session_tx(
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(),
};
ctx.db
@@ -356,6 +384,7 @@ fn compile_jump_hop_draft_tx(
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: Some(input.profile_id.clone()),
theme_text: clean_string(&config.theme_text, &input.work_title),
work_title: clean_string(&input.work_title, "跳一跳作品"),
work_description: input.work_description.trim().to_string(),
theme_tags: tags.clone(),
@@ -382,6 +411,11 @@ fn compile_jump_hop_draft_tx(
.unwrap_or_default(),
path: Some(path.clone()),
cover_composite: input.cover_composite.as_deref().and_then(clean_optional),
back_button_asset: input
.back_button_asset_json
.as_deref()
.map(parse_json)
.transpose()?,
generation_status: input
.generation_status
.clone()
@@ -416,12 +450,14 @@ fn compile_jump_hop_draft_tx(
path_json: to_json_string(&path),
cover_image_src: draft.cover_composite.clone().unwrap_or_default(),
cover_composite: draft.cover_composite.clone().unwrap_or_default(),
back_button_asset_json: draft.back_button_asset.as_ref().map(to_json_string),
generation_status: draft.generation_status.clone(),
publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: compiled_at,
published_at: None,
visible: true,
theme_text: Some(draft.theme_text.clone()),
};
upsert_work(ctx, row);
replace_session(
@@ -612,6 +648,15 @@ 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_DRAFT && work.owner_user_id != input.owner_user_id {
return Err("jump_hop draft runtime 只能由作品所有者启动".to_string());
}
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(),
@@ -622,8 +667,10 @@ 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);
upsert_run(ctx, &snapshot, input.started_at_ms, runtime_mode);
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,
@@ -651,10 +698,22 @@ 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
&& normalize_runtime_mode(row.runtime_mode.as_deref().unwrap_or_default())
== JUMP_HOP_RUNTIME_MODE_PUBLISHED
{
upsert_jump_hop_leaderboard_entry(ctx, &next, input.jumped_at_ms);
}
insert_event(
ctx,
input.client_event_id,
@@ -671,6 +730,50 @@ 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 work = find_work(ctx, &input.profile_id)?;
if work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED {
return Err("jump_hop leaderboard 只开放已发布作品".to_string());
}
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,
@@ -684,7 +787,8 @@ fn restart_jump_hop_run_tx(
)
.map_err(|error| error.to_string())?;
let next = domain_next;
upsert_run(ctx, &next, input.restarted_at_ms);
let runtime_mode = normalize_runtime_mode(source.runtime_mode.as_deref().unwrap_or_default());
upsert_run(ctx, &next, input.restarted_at_ms, runtime_mode);
insert_event(
ctx,
input.client_action_id,
@@ -706,6 +810,7 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
owner_user_id: work.owner_user_id,
source_session_id: work.source_session_id,
author_display_name: work.author_display_name,
theme_text: work.theme_text,
work_title: work.work_title,
work_description: work.work_description,
theme_tags: work.theme_tags,
@@ -771,12 +876,18 @@ fn build_session_snapshot(
fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapshot, String> {
let path = parse_json(&row.path_json)?;
let theme_text = row
.theme_text
.as_deref()
.and_then(clean_optional)
.unwrap_or_else(|| row.work_title.trim().to_string());
Ok(JumpHopWorkSnapshot {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
theme_text,
work_title: row.work_title.clone(),
work_description: row.work_description.clone(),
theme_tags: parse_tags(&row.theme_tags_json)?,
@@ -795,6 +906,12 @@ fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapsho
path,
cover_image_src: row.cover_image_src.clone(),
cover_composite: clean_optional(&row.cover_composite),
back_button_asset: row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
publication_status: row.publication_status.clone(),
publish_ready: is_publish_ready(row),
play_count: row.play_count,
@@ -821,7 +938,11 @@ fn sync_session_from_work_update(
};
let mut config = parse_config(&session.config_json)?;
config.theme_text = work.work_title.clone();
config.theme_text = work
.theme_text
.as_deref()
.and_then(clean_optional)
.unwrap_or_else(|| work.work_title.trim().to_string());
config.difficulty = work.difficulty.clone();
config.style_preset = work.style_preset.clone();
config.character_prompt = work.character_prompt.clone();
@@ -832,6 +953,7 @@ fn sync_session_from_work_update(
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: Some(work.profile_id.clone()),
theme_text: config.theme_text.clone(),
work_title: work.work_title.clone(),
work_description: work.work_description.clone(),
theme_tags: parse_tags(&work.theme_tags_json)?,
@@ -849,6 +971,12 @@ fn sync_session_from_work_update(
tile_assets: parse_json_or_default(&work.tile_assets_json),
path: Some(parse_json(&work.path_json)?),
cover_composite: clean_optional(&work.cover_composite),
back_button_asset: work
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
generation_status: work.generation_status.clone(),
};
@@ -945,7 +1073,12 @@ fn replace_session(
ctx.db.jump_hop_agent_session().insert(next);
}
fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms: i64) {
fn upsert_run(
ctx: &ReducerContext,
snapshot: &JumpHopRunSnapshot,
updated_at_ms: i64,
runtime_mode: &str,
) {
if let Some(old) = ctx
.db
.jump_hop_runtime_run()
@@ -955,9 +1088,12 @@ fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms
ctx.db.jump_hop_runtime_run().delete(old);
}
let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000));
ctx.db
.jump_hop_runtime_run()
.insert(run_row_from_snapshot(snapshot, created_at, created_at));
ctx.db.jump_hop_runtime_run().insert(run_row_from_snapshot(
snapshot,
created_at,
created_at,
runtime_mode,
));
}
fn replace_run(
@@ -971,6 +1107,7 @@ fn replace_run(
snapshot,
old.created_at,
Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)),
normalize_runtime_mode(old.runtime_mode.as_deref().unwrap_or_default()),
));
}
@@ -978,6 +1115,7 @@ fn run_row_from_snapshot(
snapshot: &JumpHopRunSnapshot,
created_at: Timestamp,
updated_at: Timestamp,
runtime_mode: &str,
) -> JumpHopRuntimeRunRow {
JumpHopRuntimeRunRow {
run_id: snapshot.run_id.clone(),
@@ -995,6 +1133,7 @@ fn run_row_from_snapshot(
snapshot_json: to_json_string(snapshot),
created_at,
updated_at,
runtime_mode: Some(normalize_runtime_mode(runtime_mode).to_string()),
}
}
@@ -1040,12 +1179,129 @@ 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()
&& row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.is_some()
}
fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot {
@@ -1054,8 +1310,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}主题的正面30度视角主题物体图集物体本身作为跳跃落点"),
end_mood_prompt: String::new(),
}
}
@@ -1235,6 +1491,8 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow {
updated_at: row.updated_at,
published_at: row.published_at,
visible: row.visible,
theme_text: row.theme_text.clone(),
back_button_asset_json: row.back_button_asset_json.clone(),
}
}
@@ -1252,6 +1510,68 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
snapshot_json: row.snapshot_json.clone(),
created_at: row.created_at,
updated_at: row.updated_at,
runtime_mode: row.runtime_mode.clone(),
}
}
#[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

@@ -56,6 +56,12 @@ pub struct JumpHopWorkProfileRow {
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
// 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。
#[default(None::<String>)]
pub(crate) theme_text: Option<String>,
// 跳一跳左上角真实可点击返回按钮的独立透明资产快照;旧行为空时运行态使用样式兜底。
#[default(None::<String>)]
pub(crate) back_button_asset_json: Option<String>,
}
#[spacetimedb::table(
@@ -77,6 +83,9 @@ pub struct JumpHopRuntimeRunRow {
pub(crate) snapshot_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
// draft / published用于隔离试玩统计和公开排行榜旧行按 published 兜底。
#[default(None::<String>)]
pub(crate) runtime_mode: Option<String>,
}
#[spacetimedb::table(
@@ -94,3 +103,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 {
@@ -54,6 +56,7 @@ pub struct JumpHopDraftCompileInput {
pub tile_atlas_asset_json: Option<String>,
pub tile_assets_json: Option<String>,
pub cover_composite: Option<String>,
pub back_button_asset_json: Option<String>,
pub generation_status: Option<String>,
pub compiled_at_micros: i64,
}
@@ -102,6 +105,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,
}
@@ -112,11 +116,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,
}
@@ -158,6 +164,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 {
@@ -187,10 +218,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,
@@ -203,6 +240,8 @@ pub struct JumpHopDraftSnapshot {
pub template_id: String,
pub template_name: String,
pub profile_id: Option<String>,
#[serde(default)]
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -216,6 +255,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<module_jump_hop::JumpHopPath>,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String,
}
@@ -244,6 +284,7 @@ pub struct JumpHopWorkSnapshot {
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -258,6 +299,7 @@ pub struct JumpHopWorkSnapshot {
pub path: module_jump_hop::JumpHopPath,
pub cover_image_src: String,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,

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,
@@ -1328,6 +1330,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
if table_name == "jump_hop_work_profile" {
// 中文注释:跳一跳主题返回按钮资产晚于首版作品表加入,旧迁移包按未生成按钮兼容。
object
.entry("back_button_asset_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
}
if table_name == "match_3_d_work_profile" || table_name == "match3d_work_profile" {

View File

@@ -338,6 +338,7 @@ fn map_custom_world_detail_entry(row: CustomWorldProfileSnapshot) -> PublicWorkD
fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalleryEntry {
let subtitle = jump_hop_difficulty_label(&row.difficulty).to_string();
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
let theme_text = row.theme_text.clone();
PublicWorkGalleryEntry {
source_type: "jump-hop".to_string(),
@@ -352,7 +353,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle
summary_text: row.work_description,
cover_image_src: empty_string_to_option(row.cover_image_src),
cover_asset_id: None,
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]),
theme_tags: fallback_tags(row.theme_tags, &[theme_text.as_str(), "跳一跳"]),
play_count: row.play_count,
remix_count: 0,
like_count: 0,
@@ -363,6 +364,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle
}
fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntry {
let theme_text = row.theme_text.clone();
let entry = PublicWorkGalleryEntry {
source_type: "jump-hop".to_string(),
work_id: row.work_id,
@@ -376,7 +378,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr
summary_text: row.work_description,
cover_image_src: empty_string_to_option(row.cover_image_src),
cover_asset_id: None,
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]),
theme_tags: fallback_tags(row.theme_tags, &[theme_text.as_str(), "跳一跳"]),
play_count: row.play_count,
remix_count: 0,
like_count: 0,
@@ -388,6 +390,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr
"sourceType": "jump-hop",
"difficulty": row.difficulty,
"stylePreset": row.style_preset,
"themeText": theme_text,
"tileAssetCount": row.tile_assets.len(),
"platformCount": row.path.platforms.len(),
"generationStatus": row.generation_status,

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