# 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
1593 lines
52 KiB
Rust
1593 lines
52 KiB
Rust
pub(crate) mod tables;
|
||
mod types;
|
||
|
||
pub use tables::*;
|
||
pub use types::*;
|
||
|
||
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
|
||
use module_jump_hop::{
|
||
JumpHopDifficulty, JumpHopPath, JumpHopRunSnapshot, apply_jump, generate_jump_hop_path,
|
||
normalize_jump_hop_seed, parse_jump_hop_difficulty, restart_run, start_run,
|
||
};
|
||
use serde::Serialize;
|
||
use serde::de::DeserializeOwned;
|
||
use spacetimedb::AnonymousViewContext;
|
||
|
||
#[spacetimedb::view(accessor = jump_hop_gallery_view, public)]
|
||
pub fn jump_hop_gallery_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGalleryViewRow> {
|
||
let mut items = ctx
|
||
.db
|
||
.jump_hop_work_profile()
|
||
.by_jump_hop_work_publication_status()
|
||
.filter(JUMP_HOP_PUBLICATION_PUBLISHED)
|
||
.filter(|row| row.visible)
|
||
.filter_map(|row| match build_gallery_view_row(&row) {
|
||
Ok(item) => Some(item),
|
||
Err(error) => {
|
||
log::warn!(
|
||
"跳一跳公开广场 view 跳过损坏的作品投影 profile_id={}: {}",
|
||
row.profile_id,
|
||
error
|
||
);
|
||
None
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
items.sort_by(|left, right| {
|
||
right
|
||
.updated_at_micros
|
||
.cmp(&left.updated_at_micros)
|
||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||
});
|
||
items
|
||
}
|
||
|
||
#[spacetimedb::view(accessor = jump_hop_gallery_card_view, public)]
|
||
pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGalleryCardViewRow> {
|
||
jump_hop_gallery_view(ctx)
|
||
.into_iter()
|
||
.map(|row| JumpHopGalleryCardViewRow {
|
||
public_work_code: build_jump_hop_public_work_code(&row.profile_id),
|
||
work_id: row.work_id,
|
||
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,
|
||
difficulty: row.difficulty,
|
||
style_preset: row.style_preset,
|
||
cover_image_src: row.cover_image_src,
|
||
publication_status: row.publication_status,
|
||
play_count: row.play_count,
|
||
updated_at_micros: row.updated_at_micros,
|
||
published_at_micros: row.published_at_micros,
|
||
generation_status: row.generation_status,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||
pub struct JumpHopGalleryViewRow {
|
||
pub work_id: String,
|
||
pub profile_id: String,
|
||
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>,
|
||
pub difficulty: String,
|
||
pub style_preset: String,
|
||
pub character_prompt: String,
|
||
pub tile_prompt: String,
|
||
pub end_mood_prompt: Option<String>,
|
||
pub character_asset: Option<JumpHopCharacterAssetSnapshot>,
|
||
pub tile_atlas_asset: Option<JumpHopCharacterAssetSnapshot>,
|
||
pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
|
||
pub path: JumpHopPath,
|
||
pub cover_image_src: String,
|
||
pub cover_composite: Option<String>,
|
||
pub publication_status: String,
|
||
pub publish_ready: bool,
|
||
pub play_count: u32,
|
||
pub generation_status: String,
|
||
pub updated_at_micros: i64,
|
||
pub published_at_micros: Option<i64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||
pub struct JumpHopGalleryCardViewRow {
|
||
pub public_work_code: String,
|
||
pub work_id: String,
|
||
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>,
|
||
pub difficulty: String,
|
||
pub style_preset: String,
|
||
pub cover_image_src: String,
|
||
pub publication_status: String,
|
||
pub play_count: u32,
|
||
pub updated_at_micros: i64,
|
||
pub published_at_micros: Option<i64>,
|
||
pub generation_status: String,
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn create_jump_hop_agent_session(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopAgentSessionCreateInput,
|
||
) -> JumpHopAgentSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| create_jump_hop_agent_session_tx(tx, input.clone())) {
|
||
Ok(session) => session_result(session),
|
||
Err(message) => session_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn get_jump_hop_agent_session(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopAgentSessionGetInput,
|
||
) -> JumpHopAgentSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_jump_hop_agent_session_tx(tx, input.clone())) {
|
||
Ok(session) => session_result(session),
|
||
Err(message) => session_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn compile_jump_hop_draft(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopDraftCompileInput,
|
||
) -> JumpHopAgentSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| compile_jump_hop_draft_tx(tx, input.clone())) {
|
||
Ok(session) => session_result(session),
|
||
Err(message) => session_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn get_jump_hop_work_profile(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopWorkGetInput,
|
||
) -> JumpHopWorkProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_jump_hop_work_profile_tx(tx, input.clone())) {
|
||
Ok(work) => work_result(work),
|
||
Err(message) => work_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn update_jump_hop_work(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopWorkUpdateInput,
|
||
) -> JumpHopWorkProcedureResult {
|
||
match ctx.try_with_tx(|tx| update_jump_hop_work_tx(tx, input.clone())) {
|
||
Ok(work) => work_result(work),
|
||
Err(message) => work_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn publish_jump_hop_work(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopWorkPublishInput,
|
||
) -> JumpHopWorkProcedureResult {
|
||
match ctx.try_with_tx(|tx| publish_jump_hop_work_tx(tx, input.clone())) {
|
||
Ok(work) => work_result(work),
|
||
Err(message) => work_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn list_jump_hop_works(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopWorksListInput,
|
||
) -> JumpHopWorksProcedureResult {
|
||
match ctx.try_with_tx(|tx| list_jump_hop_works_tx(tx, input.clone())) {
|
||
Ok(items) => JumpHopWorksProcedureResult {
|
||
ok: true,
|
||
items,
|
||
error_message: None,
|
||
},
|
||
Err(message) => JumpHopWorksProcedureResult {
|
||
ok: false,
|
||
items: Vec::new(),
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn delete_jump_hop_work(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopWorkDeleteInput,
|
||
) -> JumpHopWorksProcedureResult {
|
||
match ctx.try_with_tx(|tx| delete_jump_hop_work_tx(tx, input.clone())) {
|
||
Ok(items) => JumpHopWorksProcedureResult {
|
||
ok: true,
|
||
items,
|
||
error_message: None,
|
||
},
|
||
Err(message) => JumpHopWorksProcedureResult {
|
||
ok: false,
|
||
items: Vec::new(),
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn start_jump_hop_run(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopRunStartInput,
|
||
) -> JumpHopRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| start_jump_hop_run_tx(tx, input.clone())) {
|
||
Ok(run) => run_result(run),
|
||
Err(message) => run_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn get_jump_hop_run(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopRunGetInput,
|
||
) -> JumpHopRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_jump_hop_run_tx(tx, input.clone())) {
|
||
Ok(run) => run_result(run),
|
||
Err(message) => run_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn jump_hop_jump(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopRunJumpInput,
|
||
) -> JumpHopRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| jump_hop_jump_tx(tx, input.clone())) {
|
||
Ok(run) => run_result(run),
|
||
Err(message) => run_error(message),
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn restart_jump_hop_run(
|
||
ctx: &mut ProcedureContext,
|
||
input: JumpHopRunRestartInput,
|
||
) -> JumpHopRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| restart_jump_hop_run_tx(tx, input.clone())) {
|
||
Ok(run) => run_result(run),
|
||
Err(message) => run_error(message),
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||
require_non_empty(&input.session_id, "jump_hop session_id")?;
|
||
require_non_empty(&input.owner_user_id, "jump_hop owner_user_id")?;
|
||
if ctx
|
||
.db
|
||
.jump_hop_agent_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.is_some()
|
||
{
|
||
return Err("jump_hop_agent_session.session_id 已存在".to_string());
|
||
}
|
||
|
||
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
|
||
let config = input
|
||
.config_json
|
||
.as_deref()
|
||
.map(parse_config)
|
||
.transpose()?
|
||
.unwrap_or_else(|| default_config_from_seed(&input.seed_text));
|
||
let draft = JumpHopDraftSnapshot {
|
||
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("[]"))?,
|
||
difficulty: config.difficulty.clone(),
|
||
style_preset: config.style_preset.clone(),
|
||
character_prompt: config.character_prompt.clone(),
|
||
tile_prompt: config.tile_prompt.clone(),
|
||
end_mood_prompt: clean_optional(&config.end_mood_prompt),
|
||
character_asset: None,
|
||
tile_atlas_asset: None,
|
||
tile_assets: Vec::new(),
|
||
path: None,
|
||
cover_composite: None,
|
||
back_button_asset: None,
|
||
generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(),
|
||
};
|
||
ctx.db
|
||
.jump_hop_agent_session()
|
||
.insert(JumpHopAgentSessionRow {
|
||
session_id: input.session_id.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
seed_text: input.seed_text.trim().to_string(),
|
||
current_turn: 0,
|
||
progress_percent: 0,
|
||
stage: JUMP_HOP_STAGE_COLLECTING.to_string(),
|
||
config_json: to_json_string(&config),
|
||
draft_json: to_json_string(&draft),
|
||
last_assistant_reply: input.welcome_message_text.trim().to_string(),
|
||
published_profile_id: String::new(),
|
||
created_at,
|
||
updated_at: created_at,
|
||
});
|
||
|
||
get_jump_hop_agent_session_tx(
|
||
ctx,
|
||
JumpHopAgentSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn get_jump_hop_agent_session_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopAgentSessionGetInput,
|
||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||
let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
||
build_session_snapshot(&row)
|
||
}
|
||
|
||
fn compile_jump_hop_draft_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopDraftCompileInput,
|
||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||
require_non_empty(&input.profile_id, "jump_hop profile_id")?;
|
||
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
||
let mut config = parse_config(&session.config_json)?;
|
||
apply_compile_overrides(&mut config, &input)?;
|
||
|
||
let seed = normalize_jump_hop_seed(&input.seed_text, &session.seed_text);
|
||
let path = generate_jump_hop_path(&seed, parse_jump_hop_difficulty(&config.difficulty));
|
||
let tags = parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?;
|
||
let draft = JumpHopDraftSnapshot {
|
||
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(),
|
||
difficulty: config.difficulty.clone(),
|
||
style_preset: config.style_preset.clone(),
|
||
character_prompt: config.character_prompt.clone(),
|
||
tile_prompt: config.tile_prompt.clone(),
|
||
end_mood_prompt: clean_optional(&config.end_mood_prompt),
|
||
character_asset: input
|
||
.character_asset_json
|
||
.as_deref()
|
||
.map(parse_json)
|
||
.transpose()?,
|
||
tile_atlas_asset: input
|
||
.tile_atlas_asset_json
|
||
.as_deref()
|
||
.map(parse_json)
|
||
.transpose()?,
|
||
tile_assets: input
|
||
.tile_assets_json
|
||
.as_deref()
|
||
.map(parse_json)
|
||
.transpose()?
|
||
.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()
|
||
.unwrap_or_else(|| JUMP_HOP_GENERATION_READY.to_string()),
|
||
};
|
||
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||
let row = JumpHopWorkProfileRow {
|
||
profile_id: input.profile_id.clone(),
|
||
work_id: input.profile_id.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
source_session_id: input.session_id.clone(),
|
||
author_display_name: clean_string(&input.author_display_name, "跳一跳玩家"),
|
||
work_title: draft.work_title.clone(),
|
||
work_description: draft.work_description.clone(),
|
||
theme_tags_json: to_json_string(&tags),
|
||
difficulty: draft.difficulty.clone(),
|
||
style_preset: draft.style_preset.clone(),
|
||
character_prompt: draft.character_prompt.clone(),
|
||
tile_prompt: draft.tile_prompt.clone(),
|
||
end_mood_prompt: draft.end_mood_prompt.clone().unwrap_or_default(),
|
||
character_asset_json: draft
|
||
.character_asset
|
||
.as_ref()
|
||
.map(to_json_string)
|
||
.unwrap_or_default(),
|
||
tile_atlas_asset_json: draft
|
||
.tile_atlas_asset
|
||
.as_ref()
|
||
.map(to_json_string)
|
||
.unwrap_or_default(),
|
||
tile_assets_json: to_json_string(&draft.tile_assets),
|
||
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(
|
||
ctx,
|
||
&session,
|
||
JumpHopAgentSessionRow {
|
||
progress_percent: 100,
|
||
stage: JUMP_HOP_STAGE_DRAFT_COMPILED.to_string(),
|
||
config_json: to_json_string(&config),
|
||
draft_json: to_json_string(&draft),
|
||
published_profile_id: input.profile_id,
|
||
last_assistant_reply: "跳一跳草稿已生成,可以进入结果页试玩和发布。".to_string(),
|
||
updated_at: compiled_at,
|
||
..clone_session(&session)
|
||
},
|
||
);
|
||
|
||
get_jump_hop_agent_session_tx(
|
||
ctx,
|
||
JumpHopAgentSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn get_jump_hop_work_profile_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopWorkGetInput,
|
||
) -> Result<JumpHopWorkSnapshot, String> {
|
||
let row = find_work(ctx, &input.profile_id)?;
|
||
if !input.owner_user_id.trim().is_empty() && row.owner_user_id != input.owner_user_id {
|
||
return Err("无权访问该 jump_hop work".to_string());
|
||
}
|
||
build_work_snapshot(&row)
|
||
}
|
||
|
||
fn update_jump_hop_work_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopWorkUpdateInput,
|
||
) -> Result<JumpHopWorkSnapshot, String> {
|
||
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||
let mut next = clone_work(&row);
|
||
next.work_title = clean_string(&input.work_title, &row.work_title);
|
||
next.work_description = input.work_description.trim().to_string();
|
||
next.theme_tags_json = input.theme_tags_json.clone();
|
||
if let Some(difficulty) = input.difficulty.as_deref().and_then(clean_optional) {
|
||
next.difficulty = difficulty;
|
||
let path = generate_jump_hop_path(
|
||
&normalize_jump_hop_seed(&row.profile_id, &row.source_session_id),
|
||
parse_jump_hop_difficulty(&next.difficulty),
|
||
);
|
||
next.path_json = to_json_string(&path);
|
||
}
|
||
if let Some(style_preset) = input.style_preset.as_deref().and_then(clean_optional) {
|
||
next.style_preset = style_preset;
|
||
}
|
||
if let Some(cover) = input.cover_image_src.as_deref().and_then(clean_optional) {
|
||
next.cover_image_src = cover;
|
||
}
|
||
if let Some(cover) = input.cover_composite.as_deref().and_then(clean_optional) {
|
||
next.cover_composite = cover;
|
||
}
|
||
next.updated_at = updated_at;
|
||
replace_work(ctx, &row, next);
|
||
let updated = find_work(ctx, &row.profile_id)?;
|
||
sync_session_from_work_update(ctx, &updated, updated_at)?;
|
||
build_work_snapshot(&updated)
|
||
}
|
||
|
||
fn publish_jump_hop_work_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopWorkPublishInput,
|
||
) -> Result<JumpHopWorkSnapshot, String> {
|
||
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
|
||
replace_work(
|
||
ctx,
|
||
&row,
|
||
JumpHopWorkProfileRow {
|
||
publication_status: JUMP_HOP_PUBLICATION_PUBLISHED.to_string(),
|
||
updated_at: published_at,
|
||
published_at: Some(published_at),
|
||
..clone_work(&row)
|
||
},
|
||
);
|
||
if let Some(session) = ctx
|
||
.db
|
||
.jump_hop_agent_session()
|
||
.session_id()
|
||
.find(&row.source_session_id)
|
||
{
|
||
replace_session(
|
||
ctx,
|
||
&session,
|
||
JumpHopAgentSessionRow {
|
||
stage: JUMP_HOP_STAGE_PUBLISHED.to_string(),
|
||
updated_at: published_at,
|
||
..clone_session(&session)
|
||
},
|
||
);
|
||
}
|
||
let updated = find_work(ctx, &row.profile_id)?;
|
||
build_work_snapshot(&updated)
|
||
}
|
||
|
||
fn list_jump_hop_works_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopWorksListInput,
|
||
) -> Result<Vec<JumpHopWorkSnapshot>, String> {
|
||
let mut rows = if input.owner_user_id.trim().is_empty() {
|
||
ctx.db.jump_hop_work_profile().iter().collect::<Vec<_>>()
|
||
} else {
|
||
ctx.db
|
||
.jump_hop_work_profile()
|
||
.by_jump_hop_work_owner_user_id()
|
||
.filter(input.owner_user_id.as_str())
|
||
.collect::<Vec<_>>()
|
||
};
|
||
if input.published_only {
|
||
rows.retain(|row| row.publication_status == JUMP_HOP_PUBLICATION_PUBLISHED);
|
||
}
|
||
rows.sort_by(|left, right| {
|
||
right
|
||
.updated_at
|
||
.cmp(&left.updated_at)
|
||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||
});
|
||
rows.into_iter()
|
||
.map(|row| build_work_snapshot(&row))
|
||
.collect()
|
||
}
|
||
|
||
fn delete_jump_hop_work_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopWorkDeleteInput,
|
||
) -> Result<Vec<JumpHopWorkSnapshot>, String> {
|
||
let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||
ctx.db
|
||
.jump_hop_work_profile()
|
||
.profile_id()
|
||
.delete(&work.profile_id);
|
||
if !work.source_session_id.trim().is_empty() {
|
||
if let Some(session) = ctx
|
||
.db
|
||
.jump_hop_agent_session()
|
||
.session_id()
|
||
.find(&work.source_session_id)
|
||
.filter(|session| session.owner_user_id == input.owner_user_id)
|
||
{
|
||
ctx.db
|
||
.jump_hop_agent_session()
|
||
.session_id()
|
||
.delete(&session.session_id);
|
||
}
|
||
}
|
||
for run in ctx
|
||
.db
|
||
.jump_hop_runtime_run()
|
||
.by_jump_hop_run_profile_id()
|
||
.filter(input.profile_id.as_str())
|
||
.collect::<Vec<_>>()
|
||
{
|
||
ctx.db.jump_hop_runtime_run().run_id().delete(&run.run_id);
|
||
}
|
||
for event in ctx
|
||
.db
|
||
.jump_hop_event()
|
||
.by_jump_hop_event_profile_id()
|
||
.filter(input.profile_id.as_str())
|
||
.collect::<Vec<_>>()
|
||
{
|
||
ctx.db.jump_hop_event().event_id().delete(&event.event_id);
|
||
}
|
||
list_jump_hop_works_tx(
|
||
ctx,
|
||
JumpHopWorksListInput {
|
||
owner_user_id: input.owner_user_id,
|
||
published_only: false,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn start_jump_hop_run_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopRunStartInput,
|
||
) -> 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(),
|
||
input.owner_user_id.clone(),
|
||
input.profile_id.clone(),
|
||
path,
|
||
input.started_at_ms as u64,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let snapshot = domain_run;
|
||
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,
|
||
input.owner_user_id,
|
||
input.profile_id,
|
||
input.run_id,
|
||
JUMP_HOP_EVENT_RUN_STARTED,
|
||
None,
|
||
input.started_at_ms,
|
||
);
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn get_jump_hop_run_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopRunGetInput,
|
||
) -> Result<JumpHopRunSnapshot, String> {
|
||
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
||
parse_json(&row.snapshot_json)
|
||
}
|
||
|
||
fn jump_hop_jump_tx(
|
||
ctx: &ReducerContext,
|
||
input: JumpHopRunJumpInput,
|
||
) -> 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.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,
|
||
input.owner_user_id,
|
||
next.profile_id.clone(),
|
||
input.run_id,
|
||
JUMP_HOP_EVENT_JUMP,
|
||
next.last_jump
|
||
.as_ref()
|
||
.map(|jump| jump.result.as_str().to_string())
|
||
.or_else(|| Some(next.status.as_str().to_string())),
|
||
input.jumped_at_ms,
|
||
);
|
||
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,
|
||
) -> Result<JumpHopRunSnapshot, String> {
|
||
let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?;
|
||
let snapshot = parse_json::<JumpHopRunSnapshot>(&source.snapshot_json)?;
|
||
let domain_next = restart_run(
|
||
&snapshot,
|
||
input.next_run_id.clone(),
|
||
input.restarted_at_ms as u64,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let next = domain_next;
|
||
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,
|
||
input.owner_user_id,
|
||
next.profile_id.clone(),
|
||
input.next_run_id,
|
||
JUMP_HOP_EVENT_RUN_RESTARTED,
|
||
None,
|
||
input.restarted_at_ms,
|
||
);
|
||
Ok(next)
|
||
}
|
||
|
||
fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryViewRow, String> {
|
||
let work = build_work_snapshot(row)?;
|
||
Ok(JumpHopGalleryViewRow {
|
||
work_id: work.work_id,
|
||
profile_id: work.profile_id,
|
||
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,
|
||
difficulty: work.difficulty,
|
||
style_preset: work.style_preset,
|
||
character_prompt: work.character_prompt,
|
||
tile_prompt: work.tile_prompt,
|
||
end_mood_prompt: work.end_mood_prompt,
|
||
character_asset: work.character_asset,
|
||
tile_atlas_asset: work.tile_atlas_asset,
|
||
tile_assets: work.tile_assets,
|
||
path: work.path,
|
||
cover_image_src: work.cover_image_src,
|
||
cover_composite: work.cover_composite,
|
||
publication_status: work.publication_status,
|
||
publish_ready: work.publish_ready,
|
||
play_count: work.play_count,
|
||
generation_status: work.generation_status,
|
||
updated_at_micros: work.updated_at_micros,
|
||
published_at_micros: work.published_at_micros,
|
||
})
|
||
}
|
||
|
||
fn build_jump_hop_public_work_code(profile_id: &str) -> String {
|
||
let normalized = profile_id
|
||
.chars()
|
||
.filter(|character| character.is_ascii_alphanumeric())
|
||
.flat_map(|character| character.to_uppercase())
|
||
.collect::<String>();
|
||
let fallback = if normalized.is_empty() {
|
||
"00000000".to_string()
|
||
} else {
|
||
normalized
|
||
};
|
||
let suffix = if fallback.len() > 8 {
|
||
fallback[fallback.len() - 8..].to_string()
|
||
} else {
|
||
format!("{fallback:0>8}")
|
||
};
|
||
format!("JH-{suffix}")
|
||
}
|
||
|
||
fn build_session_snapshot(
|
||
row: &JumpHopAgentSessionRow,
|
||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||
Ok(JumpHopAgentSessionSnapshot {
|
||
session_id: row.session_id.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
seed_text: row.seed_text.clone(),
|
||
current_turn: row.current_turn,
|
||
progress_percent: row.progress_percent,
|
||
stage: row.stage.clone(),
|
||
config: parse_config(&row.config_json)?,
|
||
draft: clean_optional(&row.draft_json)
|
||
.map(|value| parse_json(&value))
|
||
.transpose()?,
|
||
last_assistant_reply: row.last_assistant_reply.clone(),
|
||
published_profile_id: clean_optional(&row.published_profile_id),
|
||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||
})
|
||
}
|
||
|
||
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)?,
|
||
difficulty: row.difficulty.clone(),
|
||
style_preset: row.style_preset.clone(),
|
||
character_prompt: row.character_prompt.clone(),
|
||
tile_prompt: row.tile_prompt.clone(),
|
||
end_mood_prompt: clean_optional(&row.end_mood_prompt),
|
||
character_asset: clean_optional(&row.character_asset_json)
|
||
.map(|value| parse_json(&value))
|
||
.transpose()?,
|
||
tile_atlas_asset: clean_optional(&row.tile_atlas_asset_json)
|
||
.map(|value| parse_json(&value))
|
||
.transpose()?,
|
||
tile_assets: parse_json_or_default(&row.tile_assets_json),
|
||
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,
|
||
generation_status: row.generation_status.clone(),
|
||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||
published_at_micros: row
|
||
.published_at
|
||
.map(|value| value.to_micros_since_unix_epoch()),
|
||
})
|
||
}
|
||
|
||
fn sync_session_from_work_update(
|
||
ctx: &ReducerContext,
|
||
work: &JumpHopWorkProfileRow,
|
||
updated_at: Timestamp,
|
||
) -> Result<(), String> {
|
||
let Some(session) = ctx
|
||
.db
|
||
.jump_hop_agent_session()
|
||
.session_id()
|
||
.find(&work.source_session_id)
|
||
else {
|
||
return Ok(());
|
||
};
|
||
|
||
let mut config = parse_config(&session.config_json)?;
|
||
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();
|
||
config.tile_prompt = work.tile_prompt.clone();
|
||
config.end_mood_prompt = work.end_mood_prompt.clone();
|
||
|
||
let draft = JumpHopDraftSnapshot {
|
||
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)?,
|
||
difficulty: work.difficulty.clone(),
|
||
style_preset: work.style_preset.clone(),
|
||
character_prompt: work.character_prompt.clone(),
|
||
tile_prompt: work.tile_prompt.clone(),
|
||
end_mood_prompt: clean_optional(&work.end_mood_prompt),
|
||
character_asset: clean_optional(&work.character_asset_json)
|
||
.map(|value| parse_json(&value))
|
||
.transpose()?,
|
||
tile_atlas_asset: clean_optional(&work.tile_atlas_asset_json)
|
||
.map(|value| parse_json(&value))
|
||
.transpose()?,
|
||
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(),
|
||
};
|
||
|
||
replace_session(
|
||
ctx,
|
||
&session,
|
||
JumpHopAgentSessionRow {
|
||
config_json: to_json_string(&config),
|
||
draft_json: to_json_string(&draft),
|
||
updated_at,
|
||
..clone_session(&session)
|
||
},
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
fn find_owned_session(
|
||
ctx: &ReducerContext,
|
||
session_id: &str,
|
||
owner_user_id: &str,
|
||
) -> Result<JumpHopAgentSessionRow, String> {
|
||
let row = ctx
|
||
.db
|
||
.jump_hop_agent_session()
|
||
.session_id()
|
||
.find(&session_id.to_string())
|
||
.ok_or_else(|| "jump_hop_agent_session 不存在".to_string())?;
|
||
if row.owner_user_id != owner_user_id {
|
||
return Err("无权访问该 jump_hop session".to_string());
|
||
}
|
||
Ok(row)
|
||
}
|
||
|
||
fn find_work(ctx: &ReducerContext, profile_id: &str) -> Result<JumpHopWorkProfileRow, String> {
|
||
ctx.db
|
||
.jump_hop_work_profile()
|
||
.profile_id()
|
||
.find(&profile_id.to_string())
|
||
.ok_or_else(|| "jump_hop_work_profile 不存在".to_string())
|
||
}
|
||
|
||
fn find_owned_work(
|
||
ctx: &ReducerContext,
|
||
profile_id: &str,
|
||
owner_user_id: &str,
|
||
) -> Result<JumpHopWorkProfileRow, String> {
|
||
let row = find_work(ctx, profile_id)?;
|
||
if row.owner_user_id != owner_user_id {
|
||
return Err("无权访问该 jump_hop work".to_string());
|
||
}
|
||
Ok(row)
|
||
}
|
||
|
||
fn find_owned_run(
|
||
ctx: &ReducerContext,
|
||
run_id: &str,
|
||
owner_user_id: &str,
|
||
) -> Result<JumpHopRuntimeRunRow, String> {
|
||
let row = ctx
|
||
.db
|
||
.jump_hop_runtime_run()
|
||
.run_id()
|
||
.find(&run_id.to_string())
|
||
.ok_or_else(|| "jump_hop_runtime_run 不存在".to_string())?;
|
||
if row.owner_user_id != owner_user_id {
|
||
return Err("无权访问该 jump_hop run".to_string());
|
||
}
|
||
Ok(row)
|
||
}
|
||
|
||
fn upsert_work(ctx: &ReducerContext, row: JumpHopWorkProfileRow) {
|
||
if let Some(old) = ctx
|
||
.db
|
||
.jump_hop_work_profile()
|
||
.profile_id()
|
||
.find(&row.profile_id)
|
||
{
|
||
ctx.db.jump_hop_work_profile().delete(old);
|
||
}
|
||
ctx.db.jump_hop_work_profile().insert(row);
|
||
}
|
||
|
||
fn replace_work(ctx: &ReducerContext, old: &JumpHopWorkProfileRow, next: JumpHopWorkProfileRow) {
|
||
ctx.db.jump_hop_work_profile().delete(clone_work(old));
|
||
ctx.db.jump_hop_work_profile().insert(next);
|
||
}
|
||
|
||
fn replace_session(
|
||
ctx: &ReducerContext,
|
||
old: &JumpHopAgentSessionRow,
|
||
next: JumpHopAgentSessionRow,
|
||
) {
|
||
ctx.db.jump_hop_agent_session().delete(clone_session(old));
|
||
ctx.db.jump_hop_agent_session().insert(next);
|
||
}
|
||
|
||
fn upsert_run(
|
||
ctx: &ReducerContext,
|
||
snapshot: &JumpHopRunSnapshot,
|
||
updated_at_ms: i64,
|
||
runtime_mode: &str,
|
||
) {
|
||
if let Some(old) = ctx
|
||
.db
|
||
.jump_hop_runtime_run()
|
||
.run_id()
|
||
.find(&snapshot.run_id)
|
||
{
|
||
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,
|
||
runtime_mode,
|
||
));
|
||
}
|
||
|
||
fn replace_run(
|
||
ctx: &ReducerContext,
|
||
old: &JumpHopRuntimeRunRow,
|
||
snapshot: &JumpHopRunSnapshot,
|
||
updated_at_ms: i64,
|
||
) {
|
||
ctx.db.jump_hop_runtime_run().delete(clone_run(old));
|
||
ctx.db.jump_hop_runtime_run().insert(run_row_from_snapshot(
|
||
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()),
|
||
));
|
||
}
|
||
|
||
fn run_row_from_snapshot(
|
||
snapshot: &JumpHopRunSnapshot,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
runtime_mode: &str,
|
||
) -> JumpHopRuntimeRunRow {
|
||
JumpHopRuntimeRunRow {
|
||
run_id: snapshot.run_id.clone(),
|
||
owner_user_id: snapshot.owner_user_id.clone(),
|
||
profile_id: snapshot.profile_id.clone(),
|
||
status: snapshot.status.as_str().to_string(),
|
||
started_at_ms: snapshot.started_at_ms as i64,
|
||
finished_at_ms: snapshot
|
||
.finished_at_ms
|
||
.map(|value| value as i64)
|
||
.unwrap_or(0),
|
||
current_platform_index: snapshot.current_platform_index,
|
||
score: snapshot.score,
|
||
combo: snapshot.combo,
|
||
snapshot_json: to_json_string(snapshot),
|
||
created_at,
|
||
updated_at,
|
||
runtime_mode: Some(normalize_runtime_mode(runtime_mode).to_string()),
|
||
}
|
||
}
|
||
|
||
fn increment_work_play_count(ctx: &ReducerContext, row: &JumpHopWorkProfileRow, played_at_ms: i64) {
|
||
replace_work(
|
||
ctx,
|
||
row,
|
||
JumpHopWorkProfileRow {
|
||
play_count: row.play_count.saturating_add(1),
|
||
updated_at: Timestamp::from_micros_since_unix_epoch(played_at_ms.saturating_mul(1000)),
|
||
..clone_work(row)
|
||
},
|
||
);
|
||
}
|
||
|
||
fn insert_event(
|
||
ctx: &ReducerContext,
|
||
event_id: String,
|
||
owner_user_id: String,
|
||
profile_id: String,
|
||
run_id: String,
|
||
event_type: &str,
|
||
result: Option<String>,
|
||
occurred_at_ms: i64,
|
||
) {
|
||
let event_id = clean_optional(&event_id).unwrap_or_else(|| {
|
||
format!(
|
||
"jump-hop-event-{}-{}-{}",
|
||
run_id, event_type, occurred_at_ms
|
||
)
|
||
});
|
||
if ctx.db.jump_hop_event().event_id().find(&event_id).is_some() {
|
||
return;
|
||
}
|
||
ctx.db.jump_hop_event().insert(JumpHopEventRow {
|
||
event_id,
|
||
owner_user_id,
|
||
profile_id,
|
||
run_id,
|
||
event_type: event_type.to_string(),
|
||
result: result.unwrap_or_default(),
|
||
occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_ms.saturating_mul(1000)),
|
||
});
|
||
}
|
||
|
||
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.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 {
|
||
let seed = clean_string(seed_text, "跳一跳");
|
||
JumpHopCreatorConfigSnapshot {
|
||
theme_text: seed.clone(),
|
||
difficulty: JumpHopDifficulty::Standard.as_str().to_string(),
|
||
style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(),
|
||
character_prompt: "内置默认 3D 角色".to_string(),
|
||
tile_prompt: format!("{seed}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"),
|
||
end_mood_prompt: String::new(),
|
||
}
|
||
}
|
||
|
||
fn apply_compile_overrides(
|
||
config: &mut JumpHopCreatorConfigSnapshot,
|
||
input: &JumpHopDraftCompileInput,
|
||
) -> Result<(), String> {
|
||
if let Some(value) = input.theme_text.as_deref().and_then(clean_optional) {
|
||
config.theme_text = value;
|
||
}
|
||
if let Some(value) = input.difficulty.as_deref().and_then(clean_optional) {
|
||
config.difficulty = value;
|
||
}
|
||
if let Some(value) = input.style_preset.as_deref().and_then(clean_optional) {
|
||
config.style_preset = value;
|
||
}
|
||
if let Some(value) = input.character_prompt.as_deref().and_then(clean_optional) {
|
||
config.character_prompt = value;
|
||
}
|
||
if let Some(value) = input.tile_prompt.as_deref().and_then(clean_optional) {
|
||
config.tile_prompt = value;
|
||
}
|
||
if let Some(value) = input.end_mood_prompt.as_deref().and_then(clean_optional) {
|
||
config.end_mood_prompt = value;
|
||
}
|
||
require_non_empty(&config.theme_text, "jump_hop theme_text")?;
|
||
require_non_empty(&config.character_prompt, "jump_hop character_prompt")?;
|
||
require_non_empty(&config.tile_prompt, "jump_hop tile_prompt")?;
|
||
Ok(())
|
||
}
|
||
|
||
fn require_non_empty(value: &str, label: &str) -> Result<(), String> {
|
||
if value.trim().is_empty() {
|
||
Err(format!("{label} 不能为空"))
|
||
} else {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn clean_optional(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
fn clean_string(value: &str, fallback: &str) -> String {
|
||
clean_optional(value).unwrap_or_else(|| fallback.to_string())
|
||
}
|
||
|
||
fn parse_config(value: &str) -> Result<JumpHopCreatorConfigSnapshot, String> {
|
||
parse_json(value)
|
||
}
|
||
|
||
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
|
||
Ok(parse_json_or_default::<Vec<String>>(value)
|
||
.into_iter()
|
||
.map(|tag| tag.trim().to_string())
|
||
.filter(|tag| !tag.is_empty())
|
||
.take(8)
|
||
.collect())
|
||
}
|
||
|
||
fn parse_json<T>(value: &str) -> Result<T, String>
|
||
where
|
||
T: DeserializeOwned,
|
||
{
|
||
serde_json::from_str(value).map_err(|error| error.to_string())
|
||
}
|
||
|
||
fn parse_json_or_default<T>(value: &str) -> T
|
||
where
|
||
T: DeserializeOwned + Default,
|
||
{
|
||
serde_json::from_str(value).unwrap_or_default()
|
||
}
|
||
|
||
fn to_json_string<T>(value: &T) -> String
|
||
where
|
||
T: Serialize,
|
||
{
|
||
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
|
||
}
|
||
|
||
fn session_result(session: JumpHopAgentSessionSnapshot) -> JumpHopAgentSessionProcedureResult {
|
||
JumpHopAgentSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
}
|
||
}
|
||
|
||
fn session_error(message: String) -> JumpHopAgentSessionProcedureResult {
|
||
JumpHopAgentSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
}
|
||
}
|
||
|
||
fn work_result(work: JumpHopWorkSnapshot) -> JumpHopWorkProcedureResult {
|
||
JumpHopWorkProcedureResult {
|
||
ok: true,
|
||
work: Some(work),
|
||
error_message: None,
|
||
}
|
||
}
|
||
|
||
fn work_error(message: String) -> JumpHopWorkProcedureResult {
|
||
JumpHopWorkProcedureResult {
|
||
ok: false,
|
||
work: None,
|
||
error_message: Some(message),
|
||
}
|
||
}
|
||
|
||
fn run_result(run: JumpHopRunSnapshot) -> JumpHopRunProcedureResult {
|
||
JumpHopRunProcedureResult {
|
||
ok: true,
|
||
run: Some(run),
|
||
error_message: None,
|
||
}
|
||
}
|
||
|
||
fn run_error(message: String) -> JumpHopRunProcedureResult {
|
||
JumpHopRunProcedureResult {
|
||
ok: false,
|
||
run: None,
|
||
error_message: Some(message),
|
||
}
|
||
}
|
||
|
||
fn clone_session(row: &JumpHopAgentSessionRow) -> JumpHopAgentSessionRow {
|
||
JumpHopAgentSessionRow {
|
||
session_id: row.session_id.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
seed_text: row.seed_text.clone(),
|
||
current_turn: row.current_turn,
|
||
progress_percent: row.progress_percent,
|
||
stage: row.stage.clone(),
|
||
config_json: row.config_json.clone(),
|
||
draft_json: row.draft_json.clone(),
|
||
last_assistant_reply: row.last_assistant_reply.clone(),
|
||
published_profile_id: row.published_profile_id.clone(),
|
||
created_at: row.created_at,
|
||
updated_at: row.updated_at,
|
||
}
|
||
}
|
||
|
||
fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow {
|
||
JumpHopWorkProfileRow {
|
||
profile_id: row.profile_id.clone(),
|
||
work_id: row.work_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(),
|
||
work_title: row.work_title.clone(),
|
||
work_description: row.work_description.clone(),
|
||
theme_tags_json: row.theme_tags_json.clone(),
|
||
difficulty: row.difficulty.clone(),
|
||
style_preset: row.style_preset.clone(),
|
||
character_prompt: row.character_prompt.clone(),
|
||
tile_prompt: row.tile_prompt.clone(),
|
||
end_mood_prompt: row.end_mood_prompt.clone(),
|
||
character_asset_json: row.character_asset_json.clone(),
|
||
tile_atlas_asset_json: row.tile_atlas_asset_json.clone(),
|
||
tile_assets_json: row.tile_assets_json.clone(),
|
||
path_json: row.path_json.clone(),
|
||
cover_image_src: row.cover_image_src.clone(),
|
||
cover_composite: row.cover_composite.clone(),
|
||
generation_status: row.generation_status.clone(),
|
||
publication_status: row.publication_status.clone(),
|
||
play_count: row.play_count,
|
||
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(),
|
||
}
|
||
}
|
||
|
||
fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
|
||
JumpHopRuntimeRunRow {
|
||
run_id: row.run_id.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
profile_id: row.profile_id.clone(),
|
||
status: row.status.clone(),
|
||
started_at_ms: row.started_at_ms,
|
||
finished_at_ms: row.finished_at_ms,
|
||
current_platform_index: row.current_platform_index,
|
||
score: row.score,
|
||
combo: row.combo,
|
||
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
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn jump_hop_delete_input_carries_owner_and_profile() {
|
||
let input = JumpHopWorkDeleteInput {
|
||
profile_id: "jump-hop-profile-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
};
|
||
|
||
assert_eq!(input.profile_id, "jump-hop-profile-1");
|
||
assert_eq!(input.owner_user_id, "user-1");
|
||
}
|
||
}
|