Add public work read model and smooth puzzle transitions
This commit is contained in:
@@ -549,6 +549,19 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_bark_battle_author_display_name(
|
||||
ctx: &AnonymousViewContext,
|
||||
owner_user_id: &str,
|
||||
) -> String {
|
||||
ctx.db
|
||||
.user_account()
|
||||
.user_id()
|
||||
.find(&owner_user_id.to_string())
|
||||
.map(|account| account.display_name.trim().to_string())
|
||||
.filter(|display_name| !display_name.is_empty())
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
|
||||
fn build_bark_battle_gallery_view_row(
|
||||
ctx: &AnonymousViewContext,
|
||||
row: &BarkBattlePublishedConfigRow,
|
||||
@@ -563,6 +576,7 @@ fn build_bark_battle_gallery_view_row(
|
||||
Ok(BarkBattleGalleryViewRow {
|
||||
work_id: row.work_id.clone(),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
author_display_name: resolve_bark_battle_author_display_name(ctx, &row.owner_user_id),
|
||||
source_draft_id: row.source_draft_id.clone(),
|
||||
config_version: row.config_version,
|
||||
ruleset_version: row.ruleset_version.clone(),
|
||||
@@ -1096,6 +1110,7 @@ mod tests {
|
||||
let row = BarkBattleGalleryViewRow {
|
||||
work_id: "BB-33333333".to_string(),
|
||||
owner_user_id: "user-3".to_string(),
|
||||
author_display_name: "玩家".to_string(),
|
||||
source_draft_id: Some("bark-battle-draft-3".to_string()),
|
||||
config_version: 1,
|
||||
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
|
||||
@@ -1115,6 +1130,9 @@ mod tests {
|
||||
published_at_micros: 1_713_686_401_234_000,
|
||||
};
|
||||
|
||||
assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]);
|
||||
assert_eq!(
|
||||
row.onomatopoeia,
|
||||
vec!["轰!".to_string(), "炸场!".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ pub struct BarkBattleRunSnapshot {
|
||||
pub struct BarkBattleGalleryViewRow {
|
||||
pub work_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub source_draft_id: Option<String>,
|
||||
pub config_version: u64,
|
||||
pub ruleset_version: String,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::*;
|
||||
use spacetimedb::AnonymousViewContext;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -4988,6 +4989,28 @@ fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldP
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn custom_world_public_profile_snapshots(
|
||||
ctx: &AnonymousViewContext,
|
||||
) -> Vec<CustomWorldProfileSnapshot> {
|
||||
let mut entries = ctx
|
||||
.db
|
||||
.custom_world_profile()
|
||||
.by_custom_world_profile_publication_status()
|
||||
.filter(CustomWorldPublicationStatus::Published)
|
||||
.filter(|row| row.deleted_at.is_none())
|
||||
.map(|row| build_custom_world_profile_snapshot(&row))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.published_at_micros
|
||||
.unwrap_or(right.updated_at_micros)
|
||||
.cmp(&left.published_at_micros.unwrap_or(left.updated_at_micros))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_custom_world_agent_session_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
row: &CustomWorldAgentSession,
|
||||
@@ -5125,6 +5148,29 @@ fn build_custom_world_gallery_entry_snapshot(
|
||||
build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts)
|
||||
}
|
||||
|
||||
pub(crate) fn custom_world_public_gallery_snapshots(
|
||||
ctx: &AnonymousViewContext,
|
||||
) -> Vec<CustomWorldGalleryEntrySnapshot> {
|
||||
let mut entries = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
.by_custom_world_gallery_owner_user_id()
|
||||
.filter(""..)
|
||||
.map(|row| {
|
||||
build_custom_world_gallery_entry_snapshot_with_recent_counts(&row, &HashMap::new())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.published_at_micros
|
||||
.cmp(&left.published_at_micros)
|
||||
.then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_custom_world_gallery_entry_snapshot_with_recent_counts(
|
||||
row: &CustomWorldGalleryEntry,
|
||||
recent_play_counts: &HashMap<String, u32>,
|
||||
@@ -5173,6 +5219,10 @@ fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
|
||||
format!("CW-{normalized_digits}")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_public_work_code(profile_id: &str) -> String {
|
||||
build_public_work_code_from_profile_id(profile_id)
|
||||
}
|
||||
|
||||
fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String {
|
||||
owner_user_id
|
||||
.trim_start_matches("user_")
|
||||
|
||||
@@ -34,6 +34,7 @@ mod gameplay;
|
||||
mod jump_hop;
|
||||
mod match3d;
|
||||
mod migration;
|
||||
mod public_work;
|
||||
mod puzzle;
|
||||
mod runtime;
|
||||
mod square_hole;
|
||||
@@ -52,6 +53,7 @@ pub use gameplay::*;
|
||||
pub use jump_hop::*;
|
||||
pub use match3d::*;
|
||||
pub use migration::*;
|
||||
pub use public_work::*;
|
||||
pub use runtime::*;
|
||||
pub use square_hole::*;
|
||||
pub use visual_novel::*;
|
||||
|
||||
788
server-rs/crates/spacetime-module/src/public_work.rs
Normal file
788
server-rs/crates/spacetime-module/src/public_work.rs
Normal file
@@ -0,0 +1,788 @@
|
||||
use crate::puzzle::{PuzzleGalleryCardViewRow, puzzle_gallery_card_view, puzzle_gallery_view};
|
||||
use crate::*;
|
||||
use module_custom_world::{CustomWorldGalleryEntrySnapshot, CustomWorldProfileSnapshot};
|
||||
use module_puzzle::PuzzleWorkProfile;
|
||||
use spacetimedb::AnonymousViewContext;
|
||||
|
||||
/// 跨玩法公开作品列表卡片读模型。
|
||||
///
|
||||
/// 该 view 只收口平台公开列表所需字段;玩法专属 runtime 配置仍留在各玩法详情 /
|
||||
/// runtime procedure 中读取。
|
||||
#[spacetimedb::view(accessor = public_work_gallery_entry, public)]
|
||||
pub fn public_work_gallery_entry(ctx: &AnonymousViewContext) -> Vec<PublicWorkGalleryEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.extend(
|
||||
puzzle_gallery_card_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_puzzle_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
custom_world_public_gallery_snapshots(ctx)
|
||||
.into_iter()
|
||||
.map(map_custom_world_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
jump_hop_gallery_card_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_jump_hop_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
wooden_fish_gallery_card_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_wooden_fish_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
match3d_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_match3d_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
square_hole_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_square_hole_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
visual_novel_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_visual_novel_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
big_fish_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_big_fish_gallery_entry),
|
||||
);
|
||||
entries.extend(
|
||||
bark_battle_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_bark_battle_gallery_entry),
|
||||
);
|
||||
|
||||
sort_public_work_gallery_entries(&mut entries);
|
||||
entries
|
||||
}
|
||||
|
||||
/// 跨玩法公开作品详情摘要读模型。
|
||||
///
|
||||
/// `detail_payload_json` 只承载平台详情页展示扩展字段,不承载正式 runtime 配置。
|
||||
#[spacetimedb::view(accessor = public_work_detail_entry, public)]
|
||||
pub fn public_work_detail_entry(ctx: &AnonymousViewContext) -> Vec<PublicWorkDetailEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.extend(
|
||||
puzzle_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_puzzle_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
custom_world_public_profile_snapshots(ctx)
|
||||
.into_iter()
|
||||
.map(map_custom_world_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
jump_hop_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_jump_hop_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
wooden_fish_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_wooden_fish_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
match3d_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_match3d_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
square_hole_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_square_hole_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
visual_novel_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_visual_novel_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
big_fish_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_big_fish_detail_entry),
|
||||
);
|
||||
entries.extend(
|
||||
bark_battle_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(map_bark_battle_detail_entry),
|
||||
);
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.sort_time_micros
|
||||
.cmp(&left.sort_time_micros)
|
||||
.then_with(|| left.source_type.cmp(&right.source_type))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct PublicWorkGalleryEntry {
|
||||
pub source_type: String,
|
||||
pub work_id: String,
|
||||
pub profile_id: String,
|
||||
pub source_session_id: Option<String>,
|
||||
pub public_work_code: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub play_count: u32,
|
||||
pub remix_count: u32,
|
||||
pub like_count: u32,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub sort_time_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct PublicWorkDetailEntry {
|
||||
pub source_type: String,
|
||||
pub work_id: String,
|
||||
pub profile_id: String,
|
||||
pub source_session_id: Option<String>,
|
||||
pub public_work_code: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub play_count: u32,
|
||||
pub remix_count: u32,
|
||||
pub like_count: u32,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub sort_time_micros: i64,
|
||||
pub detail_payload_json: String,
|
||||
}
|
||||
|
||||
fn sort_public_work_gallery_entries(entries: &mut [PublicWorkGalleryEntry]) {
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.sort_time_micros
|
||||
.cmp(&left.sort_time_micros)
|
||||
.then_with(|| left.source_type.cmp(&right.source_type))
|
||||
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn gallery_to_detail(
|
||||
entry: PublicWorkGalleryEntry,
|
||||
detail_payload_json: String,
|
||||
) -> PublicWorkDetailEntry {
|
||||
PublicWorkDetailEntry {
|
||||
source_type: entry.source_type,
|
||||
work_id: entry.work_id,
|
||||
profile_id: entry.profile_id,
|
||||
source_session_id: entry.source_session_id,
|
||||
public_work_code: entry.public_work_code,
|
||||
owner_user_id: entry.owner_user_id,
|
||||
author_display_name: entry.author_display_name,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
cover_asset_id: entry.cover_asset_id,
|
||||
theme_tags: entry.theme_tags,
|
||||
play_count: entry.play_count,
|
||||
remix_count: entry.remix_count,
|
||||
like_count: entry.like_count,
|
||||
published_at_micros: entry.published_at_micros,
|
||||
updated_at_micros: entry.updated_at_micros,
|
||||
sort_time_micros: entry.sort_time_micros,
|
||||
detail_payload_json,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_gallery_entry(row: PuzzleGalleryCardViewRow) -> PublicWorkGalleryEntry {
|
||||
let world_name = choose_non_empty(&[row.work_title.as_str(), row.level_name.as_str()]);
|
||||
let summary_text = choose_non_empty(&[row.work_description.as_str(), row.summary.as_str()]);
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "puzzle".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: row.source_session_id,
|
||||
public_work_code: build_prefixed_public_work_code("PZ", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name,
|
||||
subtitle: "拼图关卡".to_string(),
|
||||
summary_text,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: row.cover_asset_id,
|
||||
theme_tags: row.theme_tags,
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_detail_entry(row: PuzzleWorkProfile) -> PublicWorkDetailEntry {
|
||||
let entry = map_puzzle_gallery_entry(PuzzleGalleryCardViewRow {
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
source_session_id: row.source_session_id,
|
||||
author_display_name: row.author_display_name,
|
||||
work_title: row.work_title,
|
||||
work_description: row.work_description,
|
||||
level_name: row.level_name,
|
||||
summary: row.summary,
|
||||
theme_tags: row.theme_tags,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: row.cover_asset_id,
|
||||
publication_status: row.publication_status,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
published_at_micros: row.published_at_micros,
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
point_incentive_total_half_points: row.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: row.point_incentive_claimed_points,
|
||||
publish_ready: row.publish_ready,
|
||||
generation_status: None,
|
||||
});
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "puzzle",
|
||||
"levelCount": row.levels.len(),
|
||||
"coverSlides": row.levels.iter().filter_map(|level| {
|
||||
level.cover_image_src.as_ref().map(|image_src| json!({
|
||||
"id": level.level_id,
|
||||
"imageSrc": image_src,
|
||||
"label": level.level_name,
|
||||
}))
|
||||
}).collect::<Vec<_>>(),
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_custom_world_gallery_entry(row: CustomWorldGalleryEntrySnapshot) -> PublicWorkGalleryEntry {
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "custom-world".to_string(),
|
||||
work_id: format!("custom-world-work-{}", row.profile_id),
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: None,
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.world_name,
|
||||
subtitle: row.subtitle,
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec![format_custom_world_theme_mode(row.theme_mode).to_string()],
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: Some(row.published_at_micros),
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_custom_world_detail_entry(row: CustomWorldProfileSnapshot) -> PublicWorkDetailEntry {
|
||||
let public_work_code = row
|
||||
.public_work_code
|
||||
.clone()
|
||||
.unwrap_or_else(|| custom_world::build_custom_world_public_work_code(&row.profile_id));
|
||||
let published_at_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
let entry = PublicWorkGalleryEntry {
|
||||
source_type: "custom-world".to_string(),
|
||||
work_id: format!("custom-world-work-{}", row.profile_id),
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: row.source_agent_session_id.clone(),
|
||||
public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.world_name,
|
||||
subtitle: row.subtitle,
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec![format_custom_world_theme_mode(row.theme_mode).to_string()],
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: Some(published_at_micros),
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: published_at_micros,
|
||||
};
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "custom-world",
|
||||
"authorPublicUserCode": row.author_public_user_code,
|
||||
"sourceAgentSessionId": row.source_agent_session_id,
|
||||
"themeMode": format_custom_world_theme_mode(row.theme_mode),
|
||||
"playableNpcCount": row.playable_npc_count,
|
||||
"landmarkCount": row.landmark_count,
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "jump-hop".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: None,
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle,
|
||||
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, &["跳一跳"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let entry = PublicWorkGalleryEntry {
|
||||
source_type: "jump-hop".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: empty_string_to_option(row.source_session_id.clone()),
|
||||
public_work_code: build_prefixed_public_work_code("JH", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: jump_hop_difficulty_label(&row.difficulty).to_string(),
|
||||
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, &["跳一跳"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros.unwrap_or(row.updated_at_micros),
|
||||
};
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "jump-hop",
|
||||
"difficulty": row.difficulty,
|
||||
"stylePreset": row.style_preset,
|
||||
"tileAssetCount": row.tile_assets.len(),
|
||||
"platformCount": row.path.platforms.len(),
|
||||
"generationStatus": row.generation_status,
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_wooden_fish_gallery_entry(row: WoodenFishGalleryCardViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "wooden-fish".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: None,
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: "敲木鱼".to_string(),
|
||||
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, &["敲木鱼"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wooden_fish_detail_entry(row: WoodenFishGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let entry = PublicWorkGalleryEntry {
|
||||
source_type: "wooden-fish".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
source_session_id: empty_string_to_option(row.source_session_id),
|
||||
public_work_code: row.public_work_code,
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: "敲木鱼".to_string(),
|
||||
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, &["敲木鱼"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros.unwrap_or(row.updated_at_micros),
|
||||
};
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "wooden-fish",
|
||||
"hitObjectPrompt": row.hit_object_prompt,
|
||||
"floatingWords": row.floating_words,
|
||||
"generationStatus": row.generation_status,
|
||||
"hasBackgroundAsset": row.background_asset.is_some(),
|
||||
"hasHitSoundAsset": row.hit_sound_asset.is_some(),
|
||||
}));
|
||||
gallery_to_detail(entry, detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_match3d_gallery_entry(row: Match3DGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "match3d".to_string(),
|
||||
work_id: row.profile_id.clone(),
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: empty_string_to_option(row.source_session_id),
|
||||
public_work_code: build_prefixed_public_work_code("M3", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.game_name,
|
||||
subtitle: "经典消除玩法".to_string(),
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: empty_string_to_option(row.cover_asset_id),
|
||||
theme_tags: fallback_tags(row.tags, &[row.theme_text.as_str(), "抓大鹅"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "square-hole".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: empty_string_to_option(row.source_session_id),
|
||||
public_work_code: build_prefixed_public_work_code("SH", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.game_name,
|
||||
subtitle: choose_non_empty(&[row.twist_rule.as_str(), "反直觉形状分拣"]),
|
||||
summary_text: row.summary_text,
|
||||
cover_image_src: empty_string_to_option(row.cover_image_src),
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.tags, &[row.theme_text.as_str(), "方洞挑战"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_square_hole_detail_entry(row: SquareHoleGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "square-hole",
|
||||
"themeText": row.theme_text,
|
||||
"twistRule": row.twist_rule,
|
||||
"backgroundPrompt": row.background_prompt,
|
||||
"backgroundImageSrc": empty_string_to_option(row.background_image_src.clone()),
|
||||
"shapeCount": row.shape_count,
|
||||
"difficulty": row.difficulty,
|
||||
"shapeOptionCount": row.shape_options.len(),
|
||||
"holeOptionCount": row.hole_options.len(),
|
||||
}));
|
||||
gallery_to_detail(map_square_hole_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_visual_novel_gallery_entry(row: VisualNovelGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "visual-novel".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: row.source_session_id,
|
||||
public_work_code: build_prefixed_public_work_code("VN", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: row.author_display_name,
|
||||
world_name: row.work_title,
|
||||
subtitle: "视觉小说模板".to_string(),
|
||||
summary_text: row.work_description,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: fallback_tags(row.tags, &["视觉小说"]),
|
||||
play_count: row.play_count,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_visual_novel_detail_entry(row: VisualNovelGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "visual-novel",
|
||||
"sourceAssetIds": row.source_asset_ids,
|
||||
"createdAtMicros": row.created_at_micros,
|
||||
}));
|
||||
gallery_to_detail(map_visual_novel_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_big_fish_gallery_entry(row: BigFishWorkSummarySnapshot) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "big-fish".to_string(),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.source_session_id.clone(),
|
||||
source_session_id: Some(row.source_session_id.clone()),
|
||||
public_work_code: build_prefixed_public_work_code("BF", &row.source_session_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: "玩家".to_string(),
|
||||
world_name: row.title,
|
||||
subtitle: choose_non_empty(&[row.subtitle.as_str(), "大鱼吃小鱼"]),
|
||||
summary_text: row.summary,
|
||||
cover_image_src: row.cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec!["大鱼".to_string(), format!("{}级", row.level_count)],
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
published_at_micros: row.published_at_micros,
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_detail_entry(row: BigFishWorkSummarySnapshot) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "big-fish",
|
||||
"status": row.status,
|
||||
"publishReady": row.publish_ready,
|
||||
"levelCount": row.level_count,
|
||||
"levelMainImageReadyCount": row.level_main_image_ready_count,
|
||||
"levelMotionReadyCount": row.level_motion_ready_count,
|
||||
"backgroundReady": row.background_ready,
|
||||
}));
|
||||
gallery_to_detail(map_big_fish_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_bark_battle_gallery_entry(row: BarkBattleGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let cover_image_src = row
|
||||
.ui_background_image_src
|
||||
.clone()
|
||||
.or_else(|| row.player_character_image_src.clone())
|
||||
.or_else(|| row.opponent_character_image_src.clone())
|
||||
.or_else(|| Some("/creation-type-references/bark-battle.webp".to_string()));
|
||||
|
||||
PublicWorkGalleryEntry {
|
||||
source_type: "bark-battle".to_string(),
|
||||
work_id: row.work_id.clone(),
|
||||
profile_id: row.work_id.clone(),
|
||||
source_session_id: row.source_draft_id,
|
||||
public_work_code: build_bark_battle_public_work_code(&row.work_id),
|
||||
owner_user_id: row.owner_user_id,
|
||||
author_display_name: "玩家".to_string(),
|
||||
world_name: choose_non_empty(&[row.title.as_str(), "汪汪声浪大作战"]),
|
||||
subtitle: format!(
|
||||
"汪汪声浪 · {}",
|
||||
bark_battle_difficulty_label(&row.difficulty_preset)
|
||||
),
|
||||
summary_text: choose_non_empty(&[
|
||||
row.description.as_str(),
|
||||
row.theme_description.as_str(),
|
||||
"用声音能量挑战对手。",
|
||||
]),
|
||||
cover_image_src,
|
||||
cover_asset_id: None,
|
||||
theme_tags: vec![
|
||||
"汪汪声浪".to_string(),
|
||||
bark_battle_difficulty_label(&row.difficulty_preset).to_string(),
|
||||
],
|
||||
play_count: saturating_u64_to_u32(row.play_count),
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
published_at_micros: Some(row.published_at_micros),
|
||||
updated_at_micros: row.updated_at_micros,
|
||||
sort_time_micros: row.published_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bark_battle_detail_entry(row: BarkBattleGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "bark-battle",
|
||||
"difficultyPreset": row.difficulty_preset,
|
||||
"themeDescription": row.theme_description,
|
||||
"playerImageDescription": row.player_image_description,
|
||||
"opponentImageDescription": row.opponent_image_description,
|
||||
"onomatopoeia": row.onomatopoeia,
|
||||
"playerCharacterImageSrc": row.player_character_image_src,
|
||||
"opponentCharacterImageSrc": row.opponent_character_image_src,
|
||||
"uiBackgroundImageSrc": row.ui_background_image_src,
|
||||
"finishCount": row.finish_count,
|
||||
}));
|
||||
gallery_to_detail(map_bark_battle_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn build_prefixed_public_work_code(prefix: &str, value: &str) -> String {
|
||||
let normalized = normalize_public_code_text(value);
|
||||
let fallback = if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
let suffix = last_eight_padded(&fallback);
|
||||
|
||||
format!("{prefix}-{suffix}")
|
||||
}
|
||||
|
||||
fn build_bark_battle_public_work_code(work_id: &str) -> String {
|
||||
let normalized = normalize_public_code_text(work_id);
|
||||
let without_prefix = normalized
|
||||
.strip_prefix("BB")
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| normalized.clone());
|
||||
let fallback = if without_prefix.is_empty() {
|
||||
if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
} else {
|
||||
without_prefix
|
||||
};
|
||||
|
||||
format!("BB-{}", last_eight_padded(&fallback))
|
||||
}
|
||||
|
||||
fn normalize_public_code_text(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn last_eight_padded(value: &str) -> String {
|
||||
let suffix = value
|
||||
.chars()
|
||||
.rev()
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{suffix:0>8}")
|
||||
}
|
||||
|
||||
fn choose_non_empty(values: &[&str]) -> String {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn empty_string_to_option(value: String) -> Option<String> {
|
||||
let value = value.trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn fallback_tags(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
|
||||
let normalized = values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() {
|
||||
fallback
|
||||
.iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_hop_difficulty_label(value: &str) -> &'static str {
|
||||
match value.trim() {
|
||||
"easy" => "轻松节奏",
|
||||
"advanced" => "进阶跳台",
|
||||
"challenge" => "极限路线",
|
||||
_ => "标准路线",
|
||||
}
|
||||
}
|
||||
|
||||
fn bark_battle_difficulty_label(value: &str) -> &'static str {
|
||||
match value.trim() {
|
||||
"easy" => "轻松",
|
||||
"hard" => "高能",
|
||||
_ => "普通",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_custom_world_theme_mode(value: CustomWorldThemeMode) -> &'static str {
|
||||
match value {
|
||||
CustomWorldThemeMode::Martial => "martial",
|
||||
CustomWorldThemeMode::Arcane => "arcane",
|
||||
CustomWorldThemeMode::Machina => "machina",
|
||||
CustomWorldThemeMode::Tide => "tide",
|
||||
CustomWorldThemeMode::Rift => "rift",
|
||||
CustomWorldThemeMode::Mythic => "mythic",
|
||||
}
|
||||
}
|
||||
|
||||
fn saturating_u64_to_u32(value: u64) -> u32 {
|
||||
value.min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
fn json_string(value: JsonValue) -> String {
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -2081,51 +2081,54 @@ fn advance_puzzle_next_level_tx(
|
||||
let same_work_next_profile =
|
||||
selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||||
.map(|level| profile_for_single_level(¤t_profile, &level));
|
||||
let candidates = if same_work_next_profile.is_none() {
|
||||
let should_select_similar_work = input.prefer_similar_work || same_work_next_profile.is_none();
|
||||
let candidates = if should_select_similar_work {
|
||||
list_published_puzzle_profiles(ctx)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let similar_work_next_profile = if same_work_next_profile.is_none() {
|
||||
let similar_work_next_profile = if should_select_similar_work {
|
||||
let selected_candidates = select_next_profiles(
|
||||
¤t_profile,
|
||||
¤t_run.played_profile_ids,
|
||||
&candidates,
|
||||
3,
|
||||
);
|
||||
Some(
|
||||
if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}) {
|
||||
if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}) {
|
||||
Some(
|
||||
selected_candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.profile_id == target_profile_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())?
|
||||
} else {
|
||||
selected_candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?
|
||||
},
|
||||
)
|
||||
.ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())?,
|
||||
)
|
||||
} else {
|
||||
selected_candidates.into_iter().next().cloned()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next_profile = same_work_next_profile
|
||||
let similar_work_profiles = similar_work_next_profile
|
||||
.as_ref()
|
||||
.or(similar_work_next_profile.as_ref())
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let next_profile = module_puzzle::select_runtime_next_profile(
|
||||
same_work_next_profile.as_ref(),
|
||||
&similar_work_profiles,
|
||||
input.prefer_similar_work,
|
||||
)
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
let mut next_run = if same_work_next_profile.is_some() {
|
||||
module_puzzle::advance_next_level_at(
|
||||
let mut next_run = if similar_work_next_profile.is_some() {
|
||||
module_puzzle::advance_to_new_work_first_level_at(
|
||||
¤t_run,
|
||||
next_profile,
|
||||
micros_to_millis(input.advanced_at_micros),
|
||||
)
|
||||
} else {
|
||||
module_puzzle::advance_to_new_work_first_level_at(
|
||||
module_puzzle::advance_next_level_at(
|
||||
¤t_run,
|
||||
next_profile,
|
||||
micros_to_millis(input.advanced_at_micros),
|
||||
|
||||
Reference in New Issue
Block a user