Merge remote-tracking branch 'origin/master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # src/index.test.ts
This commit is contained in:
@@ -75,6 +75,17 @@ pub fn publish_bark_battle_work(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn delete_bark_battle_work(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BarkBattleWorkDeleteInput,
|
||||
) -> BarkBattleProcedureResult {
|
||||
match ctx.try_with_tx(|tx| delete_bark_battle_work_tx(tx, input.clone())) {
|
||||
Ok(()) => bark_battle_empty_result(),
|
||||
Err(error) => bark_battle_error_result(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_bark_battle_runtime_config(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -286,6 +297,111 @@ fn publish_bark_battle_work_tx(
|
||||
Ok(runtime_config_snapshot(&published))
|
||||
}
|
||||
|
||||
fn delete_bark_battle_work_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BarkBattleWorkDeleteInput,
|
||||
) -> Result<(), String> {
|
||||
require_non_empty(&input.work_id, "bark_battle work_id")?;
|
||||
require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?;
|
||||
let drafts = ctx
|
||||
.db
|
||||
.bark_battle_draft_config()
|
||||
.by_bark_battle_draft_work_id()
|
||||
.filter(input.work_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let published = ctx
|
||||
.db
|
||||
.bark_battle_published_config()
|
||||
.work_id()
|
||||
.find(&input.work_id);
|
||||
if drafts.is_empty() && published.is_none() {
|
||||
return Err("bark_battle work 不存在".to_string());
|
||||
}
|
||||
if drafts
|
||||
.iter()
|
||||
.any(|draft| draft.owner_user_id != input.owner_user_id)
|
||||
|| published
|
||||
.as_ref()
|
||||
.is_some_and(|row| row.owner_user_id != input.owner_user_id)
|
||||
{
|
||||
return Err("bark_battle work owner 不匹配".to_string());
|
||||
}
|
||||
|
||||
for draft in drafts {
|
||||
ctx.db
|
||||
.bark_battle_draft_config()
|
||||
.draft_id()
|
||||
.delete(&draft.draft_id);
|
||||
}
|
||||
if let Some(published) = published {
|
||||
ctx.db
|
||||
.bark_battle_published_config()
|
||||
.work_id()
|
||||
.delete(&published.work_id);
|
||||
}
|
||||
for run in ctx
|
||||
.db
|
||||
.bark_battle_runtime_run()
|
||||
.by_bark_battle_run_work_id()
|
||||
.filter(input.work_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.bark_battle_runtime_run()
|
||||
.run_id()
|
||||
.delete(&run.run_id);
|
||||
}
|
||||
for score in ctx
|
||||
.db
|
||||
.bark_battle_score_record()
|
||||
.by_bark_battle_score_work_id()
|
||||
.filter(input.work_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.bark_battle_score_record()
|
||||
.score_id()
|
||||
.delete(&score.score_id);
|
||||
}
|
||||
for entry in ctx
|
||||
.db
|
||||
.bark_battle_leaderboard_entry()
|
||||
.iter()
|
||||
.filter(|entry| entry.work_id == input.work_id)
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.bark_battle_leaderboard_entry()
|
||||
.leaderboard_entry_id()
|
||||
.delete(&entry.leaderboard_entry_id);
|
||||
}
|
||||
for personal_best in ctx
|
||||
.db
|
||||
.bark_battle_personal_best_projection()
|
||||
.by_bark_battle_personal_best_work_id()
|
||||
.filter(input.work_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.bark_battle_personal_best_projection()
|
||||
.personal_best_id()
|
||||
.delete(&personal_best.personal_best_id);
|
||||
}
|
||||
if ctx
|
||||
.db
|
||||
.bark_battle_work_stats_projection()
|
||||
.work_id()
|
||||
.find(&input.work_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db
|
||||
.bark_battle_work_stats_projection()
|
||||
.work_id()
|
||||
.delete(&input.work_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_bark_battle_runtime_config_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BarkBattleRuntimeConfigGetInput,
|
||||
@@ -763,6 +879,16 @@ fn bark_battle_run_result(run: BarkBattleRunSnapshot) -> BarkBattleProcedureResu
|
||||
}
|
||||
}
|
||||
|
||||
fn bark_battle_empty_result() -> BarkBattleProcedureResult {
|
||||
BarkBattleProcedureResult {
|
||||
ok: true,
|
||||
draft_config: None,
|
||||
runtime_config: None,
|
||||
run: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bark_battle_error_result(error: String) -> BarkBattleProcedureResult {
|
||||
BarkBattleProcedureResult {
|
||||
ok: false,
|
||||
@@ -1043,6 +1169,17 @@ mod tests {
|
||||
assert!(result.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_battle_delete_input_carries_owner_and_work() {
|
||||
let input = BarkBattleWorkDeleteInput {
|
||||
work_id: "BB-12345678".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(input.work_id, "BB-12345678");
|
||||
assert_eq!(input.owner_user_id, "user-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_light_editor_config_before_publish() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -53,6 +53,12 @@ pub struct BarkBattleWorkPublishInput {
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct BarkBattleWorkDeleteInput {
|
||||
pub work_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct BarkBattleRuntimeConfigGetInput {
|
||||
pub work_id: String,
|
||||
|
||||
@@ -1662,9 +1662,7 @@ fn get_custom_world_gallery_detail_record(
|
||||
.find(&input.profile_id)
|
||||
.filter(|row| {
|
||||
row.owner_user_id == input.owner_user_id
|
||||
&& row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& is_custom_world_profile_publicly_interactive(row)
|
||||
});
|
||||
|
||||
let gallery_entry = ctx
|
||||
@@ -1712,8 +1710,7 @@ fn get_custom_world_gallery_detail_record_by_code(
|
||||
.find(&row.profile_id)
|
||||
.filter(|profile_row| {
|
||||
profile_row.owner_user_id == row.owner_user_id
|
||||
&& profile_row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& profile_row.deleted_at.is_none()
|
||||
&& is_custom_world_profile_publicly_interactive(profile_row)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1756,12 +1753,7 @@ fn remix_custom_world_profile_record(
|
||||
.profile_id()
|
||||
.find(&source_profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == source_owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.filter(is_custom_world_profile_publicly_interactive)
|
||||
.ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?;
|
||||
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
|
||||
|
||||
@@ -1859,12 +1851,7 @@ fn record_custom_world_profile_play_record(
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.filter(is_custom_world_profile_publicly_interactive)
|
||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
|
||||
@@ -1932,12 +1919,7 @@ fn record_custom_world_profile_like_record(
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.filter(is_custom_world_profile_publicly_interactive)
|
||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
|
||||
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
|
||||
|
||||
@@ -1998,6 +1980,18 @@ fn record_custom_world_profile_like_record(
|
||||
))
|
||||
}
|
||||
|
||||
fn is_custom_world_profile_publicly_interactive(row: &CustomWorldProfile) -> bool {
|
||||
// 历史公开作品可能缺少 published_at;公开互动只按发布、未删除、可见判断。
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
}
|
||||
|
||||
fn resolve_custom_world_published_at(row: &CustomWorldProfile) -> Timestamp {
|
||||
// gallery 展示与同步兼容旧数据,用 updated_at 兜底公开时间。
|
||||
row.published_at.unwrap_or(row.updated_at)
|
||||
}
|
||||
|
||||
fn list_custom_world_work_snapshots(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldWorksListInput,
|
||||
@@ -4832,9 +4826,10 @@ fn sync_custom_world_gallery_entry_from_profile(
|
||||
ctx: &ReducerContext,
|
||||
profile: &CustomWorldProfile,
|
||||
) -> Result<CustomWorldGalleryEntrySnapshot, String> {
|
||||
let published_at = profile
|
||||
.published_at
|
||||
.ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?;
|
||||
if profile.publication_status != CustomWorldPublicationStatus::Published {
|
||||
return Err("custom_world profile 未发布,无法同步 gallery".to_string());
|
||||
}
|
||||
let published_at = resolve_custom_world_published_at(profile);
|
||||
|
||||
ctx.db
|
||||
.custom_world_gallery_entry()
|
||||
@@ -4881,10 +4876,6 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(),
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for profile in published_profiles {
|
||||
if profile.published_at.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let existing_gallery_entry = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
@@ -5483,6 +5474,78 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_public_interactions_accept_legacy_missing_published_at() {
|
||||
fn build_profile(
|
||||
publication_status: CustomWorldPublicationStatus,
|
||||
published_at: Option<Timestamp>,
|
||||
deleted_at: Option<Timestamp>,
|
||||
visible: bool,
|
||||
) -> CustomWorldProfile {
|
||||
CustomWorldProfile {
|
||||
profile_id: "profile-legacy".to_string(),
|
||||
owner_user_id: "user-legacy".to_string(),
|
||||
public_work_code: Some("CW-3A9EC89B".to_string()),
|
||||
author_public_user_code: Some("SY-00000001".to_string()),
|
||||
source_agent_session_id: Some("session-legacy".to_string()),
|
||||
publication_status,
|
||||
world_name: "旧公开世界".to_string(),
|
||||
subtitle: String::new(),
|
||||
summary_text: String::new(),
|
||||
theme_mode: CustomWorldThemeMode::Mythic,
|
||||
cover_image_src: None,
|
||||
profile_payload_json: "{}".to_string(),
|
||||
playable_npc_count: 0,
|
||||
landmark_count: 0,
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
author_display_name: "玩家".to_string(),
|
||||
published_at,
|
||||
deleted_at,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(20),
|
||||
visible,
|
||||
}
|
||||
}
|
||||
|
||||
let legacy_published =
|
||||
build_profile(CustomWorldPublicationStatus::Published, None, None, true);
|
||||
assert!(is_custom_world_profile_publicly_interactive(
|
||||
&legacy_published
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_custom_world_published_at(&legacy_published).to_micros_since_unix_epoch(),
|
||||
20
|
||||
);
|
||||
|
||||
let current_published = build_profile(
|
||||
CustomWorldPublicationStatus::Published,
|
||||
Some(Timestamp::from_micros_since_unix_epoch(10)),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_custom_world_published_at(¤t_published).to_micros_since_unix_epoch(),
|
||||
10
|
||||
);
|
||||
|
||||
assert!(!is_custom_world_profile_publicly_interactive(
|
||||
&build_profile(CustomWorldPublicationStatus::Draft, None, None, true,)
|
||||
));
|
||||
assert!(!is_custom_world_profile_publicly_interactive(
|
||||
&build_profile(
|
||||
CustomWorldPublicationStatus::Published,
|
||||
None,
|
||||
Some(Timestamp::from_micros_since_unix_epoch(30)),
|
||||
true,
|
||||
)
|
||||
));
|
||||
assert!(!is_custom_world_profile_publicly_interactive(
|
||||
&build_profile(CustomWorldPublicationStatus::Published, None, None, false,)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() {
|
||||
fn build_test_custom_world_profile(
|
||||
|
||||
@@ -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>,
|
||||
@@ -201,6 +204,25 @@ pub fn list_jump_hop_works(
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -245,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,
|
||||
@@ -272,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("[]"))?,
|
||||
@@ -285,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
|
||||
@@ -337,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(),
|
||||
@@ -363,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()
|
||||
@@ -397,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(
|
||||
@@ -537,12 +592,71 @@ fn list_jump_hop_works_tx(
|
||||
.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(),
|
||||
@@ -553,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,
|
||||
@@ -582,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,
|
||||
@@ -602,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,
|
||||
@@ -615,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,
|
||||
@@ -637,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,
|
||||
@@ -702,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)?,
|
||||
@@ -726,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,
|
||||
@@ -752,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();
|
||||
@@ -763,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)?,
|
||||
@@ -780,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(),
|
||||
};
|
||||
|
||||
@@ -876,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()
|
||||
@@ -886,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(
|
||||
@@ -902,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()),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -909,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(),
|
||||
@@ -926,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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,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 {
|
||||
@@ -985,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}主题的3D立方体主题身份方块包装图集"),
|
||||
end_mood_prompt: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1166,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,5 +1510,78 @@ 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
|
||||
));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -79,6 +82,12 @@ pub struct JumpHopWorkPublishInput {
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct JumpHopWorkDeleteInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct JumpHopWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
@@ -96,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,
|
||||
}
|
||||
@@ -106,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,
|
||||
}
|
||||
@@ -152,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 {
|
||||
@@ -181,14 +218,48 @@ 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,
|
||||
pub landing_radius: f32,
|
||||
#[serde(default)]
|
||||
pub face_assets: Option<JumpHopTileFaceAssetsSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopTileFaceAssetSnapshot {
|
||||
pub face: String,
|
||||
pub asset_id: String,
|
||||
pub image_src: String,
|
||||
pub image_object_key: String,
|
||||
pub asset_object_id: String,
|
||||
pub generation_provider: String,
|
||||
pub prompt: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub source_atlas_cell: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JumpHopTileFaceAssetsSnapshot {
|
||||
pub top: JumpHopTileFaceAssetSnapshot,
|
||||
pub front: JumpHopTileFaceAssetSnapshot,
|
||||
pub right: JumpHopTileFaceAssetSnapshot,
|
||||
pub back: JumpHopTileFaceAssetSnapshot,
|
||||
pub left: JumpHopTileFaceAssetSnapshot,
|
||||
pub bottom: JumpHopTileFaceAssetSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
|
||||
@@ -197,6 +268,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>,
|
||||
@@ -210,6 +283,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,
|
||||
}
|
||||
|
||||
@@ -238,6 +312,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>,
|
||||
@@ -252,6 +327,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -252,6 +253,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,
|
||||
@@ -1191,6 +1193,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("event_banners_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("public_work_interactions_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if table_name == "creation_entry_type_config" {
|
||||
@@ -1343,6 +1348,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
.entry("board_background_prompt".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
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" {
|
||||
|
||||
@@ -408,6 +408,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(),
|
||||
@@ -422,7 +423,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,
|
||||
@@ -433,6 +434,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,
|
||||
@@ -446,7 +448,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,
|
||||
@@ -458,6 +460,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,
|
||||
@@ -550,18 +553,6 @@ fn map_match3d_gallery_entry(row: Match3DGalleryViewRow) -> PublicWorkGalleryEnt
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_detail_entry(row: Match3DGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "match3d",
|
||||
"themeText": row.theme_text,
|
||||
"referenceImageSrc": row.reference_image_src,
|
||||
"clearCount": row.clear_count,
|
||||
"difficulty": row.difficulty,
|
||||
"generatedItemAssetsReady": row.generated_item_assets_json.as_ref().is_some_and(|value| !value.trim().is_empty()),
|
||||
}));
|
||||
gallery_to_detail(map_match3d_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_square_hole_gallery_entry(row: SquareHoleGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use module_custom_world::CustomWorldPublicationStatus;
|
||||
use module_puzzle::PuzzlePublicationStatus;
|
||||
|
||||
const SOURCE_TYPE_PUZZLE: &str = "puzzle";
|
||||
const SOURCE_TYPE_PUZZLE_CLEAR: &str = "puzzle-clear";
|
||||
const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world";
|
||||
const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop";
|
||||
const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish";
|
||||
@@ -63,6 +64,7 @@ fn list_work_visibility_tx(
|
||||
|
||||
let mut entries = Vec::new();
|
||||
entries.extend(list_puzzle_work_visibility(ctx));
|
||||
entries.extend(list_puzzle_clear_work_visibility(ctx));
|
||||
entries.extend(list_custom_world_work_visibility(ctx));
|
||||
entries.extend(list_jump_hop_work_visibility(ctx));
|
||||
entries.extend(list_wooden_fish_work_visibility(ctx));
|
||||
@@ -85,6 +87,9 @@ fn update_work_visibility_tx(
|
||||
|
||||
match source_type.as_str() {
|
||||
SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible),
|
||||
SOURCE_TYPE_PUZZLE_CLEAR => {
|
||||
update_puzzle_clear_work_visibility(ctx, &profile_id, input.visible)
|
||||
}
|
||||
SOURCE_TYPE_CUSTOM_WORLD => {
|
||||
update_custom_world_work_visibility(ctx, &profile_id, input.visible)
|
||||
}
|
||||
@@ -167,6 +172,63 @@ fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisib
|
||||
}
|
||||
}
|
||||
|
||||
fn list_puzzle_clear_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
|
||||
ctx.db
|
||||
.puzzle_clear_work_profile()
|
||||
.by_puzzle_clear_work_publication_status()
|
||||
.filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED)
|
||||
.map(|row| puzzle_clear_work_visibility_snapshot(&row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn update_puzzle_clear_work_visibility(
|
||||
ctx: &ReducerContext,
|
||||
profile_id: &str,
|
||||
visible: bool,
|
||||
) -> Result<AdminWorkVisibilitySnapshot, String> {
|
||||
let row = ctx
|
||||
.db
|
||||
.puzzle_clear_work_profile()
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.ok_or_else(|| "拼消消作品不存在".to_string())?;
|
||||
if row.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED {
|
||||
return Err("只能修改已发布拼消消作品可见性".to_string());
|
||||
}
|
||||
let next = PuzzleClearWorkProfileRow { visible, ..row };
|
||||
let snapshot = puzzle_clear_work_visibility_snapshot(&next);
|
||||
let profile_id = next.profile_id.clone();
|
||||
ctx.db
|
||||
.puzzle_clear_work_profile()
|
||||
.profile_id()
|
||||
.delete(&profile_id);
|
||||
ctx.db.puzzle_clear_work_profile().insert(next);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn puzzle_clear_work_visibility_snapshot(
|
||||
row: &PuzzleClearWorkProfileRow,
|
||||
) -> AdminWorkVisibilitySnapshot {
|
||||
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
|
||||
AdminWorkVisibilitySnapshot {
|
||||
source_type: SOURCE_TYPE_PUZZLE_CLEAR.to_string(),
|
||||
work_id: row.work_id.clone(),
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
|
||||
public_work_code: build_prefixed_public_work_code("PC", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
author_display_name: row.author_display_name.clone(),
|
||||
title: choose_non_empty(&[row.work_title.as_str(), row.theme_prompt.as_str(), "拼消消"]),
|
||||
subtitle: "拼消消".to_string(),
|
||||
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
|
||||
visible: row.visible,
|
||||
published_at_micros: row
|
||||
.published_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: sort_time,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_custom_world_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
|
||||
ctx.db
|
||||
.custom_world_profile()
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct CreationEntryConfig {
|
||||
/// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_banners_json: Option<String>,
|
||||
/// 公开作品点赞 / 改造能力配置,旧库为空时由读取层按默认矩阵兜底。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) public_work_interactions_json: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -109,6 +112,26 @@ pub fn upsert_creation_entry_event_banners_config(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
/// 后台保存公开作品点赞 / 改造能力配置的过程入口。
|
||||
pub fn upsert_public_work_interaction_config(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> CreationEntryConfigProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_public_work_interaction_config_in_tx(tx, input.clone())) {
|
||||
Ok(record) => CreationEntryConfigProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => CreationEntryConfigProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_creation_entry_type_config_in_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CreationEntryTypeAdminUpsertInput,
|
||||
@@ -171,6 +194,33 @@ fn upsert_creation_entry_event_banners_config_in_tx(
|
||||
get_or_seed_creation_entry_config_snapshot(ctx)
|
||||
}
|
||||
|
||||
/// 在事务内归一化公开作品互动配置 JSON 并更新全局入口配置表头。
|
||||
fn upsert_public_work_interaction_config_in_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: PublicWorkInteractionConfigAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigSnapshot, String> {
|
||||
seed_creation_entry_config_if_missing(ctx);
|
||||
let now = ctx.timestamp;
|
||||
let config_id = CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string();
|
||||
let Some(header) = ctx.db.creation_entry_config().config_id().find(&config_id) else {
|
||||
return Err("创作入口配置初始化失败".to_string());
|
||||
};
|
||||
let public_work_interactions_json =
|
||||
module_runtime::normalize_public_work_interaction_config_json(
|
||||
&input.public_work_interactions_json,
|
||||
)?;
|
||||
|
||||
ctx.db
|
||||
.creation_entry_config()
|
||||
.config_id()
|
||||
.update(CreationEntryConfig {
|
||||
updated_at: now,
|
||||
public_work_interactions_json: Some(public_work_interactions_json),
|
||||
..header
|
||||
});
|
||||
get_or_seed_creation_entry_config_snapshot(ctx)
|
||||
}
|
||||
|
||||
fn get_or_seed_creation_entry_config_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
) -> Result<CreationEntryConfigSnapshot, String> {
|
||||
@@ -247,6 +297,7 @@ fn get_or_seed_creation_entry_config_snapshot(
|
||||
event_banners_json: header.event_banners_json,
|
||||
creation_types,
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
public_work_interactions_json: header.public_work_interactions_json,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,6 +327,9 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
|
||||
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
public_work_interactions_json: Some(
|
||||
module_runtime::default_public_work_interaction_config_json(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,6 +350,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 +502,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()
|
||||
|
||||
@@ -1912,174 +1912,6 @@ pub(crate) fn build_profile_save_archive_snapshot_from_row(
|
||||
}
|
||||
}
|
||||
|
||||
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
|
||||
value
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn resolve_profile_world_snapshot_meta(
|
||||
game_state: Option<&serde_json::Map<String, JsonValue>>,
|
||||
) -> Option<RuntimeProfileWorldSnapshotMeta> {
|
||||
let game_state = game_state?;
|
||||
let custom_world_profile = game_state
|
||||
.get("customWorldProfile")
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let profile_id = read_string_from_json(custom_world_profile.get("id"));
|
||||
let world_title = read_string_from_json(custom_world_profile.get("name"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("title")));
|
||||
if profile_id.is_some() || world_title.is_some() {
|
||||
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
|
||||
return Some(RuntimeProfileWorldSnapshotMeta {
|
||||
world_key: profile_id
|
||||
.as_ref()
|
||||
.map(|profile_id| format!("custom:{profile_id}"))
|
||||
.unwrap_or_else(|| format!("custom:{world_title}")),
|
||||
owner_user_id: None,
|
||||
profile_id,
|
||||
world_type: Some("CUSTOM".to_string()),
|
||||
world_title,
|
||||
world_subtitle: read_string_from_json(custom_world_profile.get("summary"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let world_type = read_string_from_json(game_state.get("worldType"))?;
|
||||
let current_scene_preset = game_state
|
||||
.get("currentScenePreset")
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
Some(RuntimeProfileWorldSnapshotMeta {
|
||||
world_key: format!("builtin:{world_type}"),
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
world_type: Some(world_type.clone()),
|
||||
world_title: current_scene_preset
|
||||
.and_then(|preset| read_string_from_json(preset.get("name")))
|
||||
.unwrap_or_else(|| build_builtin_world_title(&world_type)),
|
||||
world_subtitle: current_scene_preset
|
||||
.and_then(|preset| {
|
||||
read_string_from_json(preset.get("summary"))
|
||||
.or_else(|| read_string_from_json(preset.get("description")))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_profile_save_archive_meta(
|
||||
game_state: &JsonValue,
|
||||
current_story_json: Option<&str>,
|
||||
) -> Option<RuntimeProfileSaveArchiveMeta> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_state_object = game_state.as_object();
|
||||
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
|
||||
let story_engine_memory = game_state_object
|
||||
.and_then(|state| state.get("storyEngineMemory"))
|
||||
.and_then(JsonValue::as_object);
|
||||
let continue_game_digest = story_engine_memory
|
||||
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
|
||||
let current_story_text = parse_optional_json_str(current_story_json)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|story| story.as_object().cloned())
|
||||
.and_then(|story| read_string_from_json(story.get("text")));
|
||||
let custom_world_profile = game_state_object
|
||||
.and_then(|state| state.get("customWorldProfile"))
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let world_name = read_string_from_json(custom_world_profile.get("name"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("title")))
|
||||
.unwrap_or_else(|| world_meta.world_title.clone());
|
||||
let subtitle = read_string_from_json(custom_world_profile.get("summary"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||||
.unwrap_or_else(|| world_meta.world_subtitle.clone());
|
||||
let summary_text = continue_game_digest
|
||||
.or(current_story_text)
|
||||
.or_else(|| {
|
||||
if subtitle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(subtitle.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||||
|
||||
return Some(RuntimeProfileSaveArchiveMeta {
|
||||
world_key: world_meta.world_key,
|
||||
owner_user_id: world_meta.owner_user_id,
|
||||
profile_id: world_meta.profile_id,
|
||||
world_type: world_meta.world_type,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")),
|
||||
});
|
||||
}
|
||||
|
||||
let summary_text = continue_game_digest
|
||||
.or(current_story_text)
|
||||
.or_else(|| {
|
||||
if world_meta.world_subtitle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(world_meta.world_subtitle.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||||
let current_scene_preset = game_state_object
|
||||
.and_then(|state| state.get("currentScenePreset"))
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
Some(RuntimeProfileSaveArchiveMeta {
|
||||
world_key: world_meta.world_key,
|
||||
owner_user_id: world_meta.owner_user_id,
|
||||
profile_id: world_meta.profile_id,
|
||||
world_type: world_meta.world_type,
|
||||
world_name: world_meta.world_title,
|
||||
subtitle: world_meta.world_subtitle.clone(),
|
||||
summary_text,
|
||||
cover_image_src: current_scene_preset
|
||||
.and_then(|preset| read_string_from_json(preset.get("imageSrc"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
read_string_from_json(game_state.get("runtimeMode")).as_deref(),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
"XIANXIA" => "仙侠世界".to_string(),
|
||||
_ => "叙事世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_dashboard_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileDashboardGetInput,
|
||||
|
||||
@@ -202,6 +202,25 @@ pub fn list_wooden_fish_works(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn delete_wooden_fish_work(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: WoodenFishWorkDeleteInput,
|
||||
) -> WoodenFishWorksProcedureResult {
|
||||
match ctx.try_with_tx(|tx| delete_wooden_fish_work_tx(tx, input.clone())) {
|
||||
Ok(items) => WoodenFishWorksProcedureResult {
|
||||
ok: true,
|
||||
items,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => WoodenFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items: Vec::new(),
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn start_wooden_fish_run(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -598,6 +617,62 @@ fn list_wooden_fish_works_tx(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn delete_wooden_fish_work_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: WoodenFishWorkDeleteInput,
|
||||
) -> Result<Vec<WoodenFishWorkSnapshot>, String> {
|
||||
let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||||
ctx.db
|
||||
.wooden_fish_work_profile()
|
||||
.profile_id()
|
||||
.delete(&work.profile_id);
|
||||
if !work.source_session_id.trim().is_empty() {
|
||||
if let Some(session) = ctx
|
||||
.db
|
||||
.wooden_fish_agent_session()
|
||||
.session_id()
|
||||
.find(&work.source_session_id)
|
||||
.filter(|session| session.owner_user_id == input.owner_user_id)
|
||||
{
|
||||
ctx.db
|
||||
.wooden_fish_agent_session()
|
||||
.session_id()
|
||||
.delete(&session.session_id);
|
||||
}
|
||||
}
|
||||
for run in ctx
|
||||
.db
|
||||
.wooden_fish_runtime_run()
|
||||
.by_wooden_fish_run_profile_id()
|
||||
.filter(input.profile_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.wooden_fish_runtime_run()
|
||||
.run_id()
|
||||
.delete(&run.run_id);
|
||||
}
|
||||
for event in ctx
|
||||
.db
|
||||
.wooden_fish_event()
|
||||
.by_wooden_fish_event_profile_id()
|
||||
.filter(input.profile_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.wooden_fish_event()
|
||||
.event_id()
|
||||
.delete(&event.event_id);
|
||||
}
|
||||
list_wooden_fish_works_tx(
|
||||
ctx,
|
||||
WoodenFishWorksListInput {
|
||||
owner_user_id: input.owner_user_id,
|
||||
published_only: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn start_wooden_fish_run_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: WoodenFishRunStartInput,
|
||||
@@ -1363,6 +1438,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_delete_input_carries_owner_and_profile() {
|
||||
let input = WoodenFishWorkDeleteInput {
|
||||
profile_id: "wooden-fish-profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(input.profile_id, "wooden-fish-profile-1");
|
||||
assert_eq!(input.owner_user_id, "user-1");
|
||||
}
|
||||
|
||||
fn published_ready_work_without_back_button() -> WoodenFishWorkProfileRow {
|
||||
let now = Timestamp::from_micros_since_unix_epoch(1_770_000_000_000_000);
|
||||
WoodenFishWorkProfileRow {
|
||||
|
||||
@@ -84,6 +84,12 @@ pub struct WoodenFishWorkPublishInput {
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct WoodenFishWorkDeleteInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct WoodenFishWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
|
||||
Reference in New Issue
Block a user