Merge branch 'master' into codex/frontend-error-dialogs

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/generated_asset_sheets.rs
This commit is contained in:
kdletters
2026-05-26 22:10:41 +08:00
175 changed files with 4760 additions and 465 deletions

View File

@@ -61,6 +61,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::square_hole::router(state.clone()))
.merge(modules::jump_hop::router(state.clone()))
.merge(modules::wooden_fish::router(state.clone()))
.merge(modules::public_work::router(state.clone()))
.merge(modules::puzzle::router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route(

View File

@@ -702,7 +702,7 @@ pub async fn list_custom_world_gallery(
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.list_custom_world_gallery_entries()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
@@ -713,7 +713,8 @@ pub async fn list_custom_world_gallery(
CustomWorldGalleryResponse {
entries: entries
.into_iter()
.map(|entry| map_custom_world_gallery_card_response(&state, entry))
.filter(|entry| entry.source_type == "custom-world")
.map(|entry| map_public_work_custom_world_gallery_card_response(&state, entry))
.collect(),
},
))

View File

@@ -149,6 +149,43 @@ pub(super) fn map_custom_world_gallery_card_response(
}
}
pub(super) fn map_public_work_custom_world_gallery_card_response(
state: &AppState,
entry: spacetime_client::PublicWorkGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
None,
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.unwrap_or_default(),
visibility: "published".to_string(),
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry
.theme_tags
.first()
.cloned()
.unwrap_or_else(|| "mythic".to_string()),
playable_npc_count: 0,
landmark_count: 0,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
pub(super) fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {

View File

@@ -1,4 +1,4 @@
use axum::http::StatusCode;
use axum::http::StatusCode;
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
use crate::{

View File

@@ -61,6 +61,7 @@ mod platform_errors;
mod process_metrics;
mod profile_identity;
mod prompt;
mod public_work;
mod puzzle;
mod puzzle_agent_turn;
mod puzzle_gallery_cache;

View File

@@ -11,6 +11,7 @@ pub mod jump_hop;
pub mod match3d;
pub mod platform;
pub mod profile;
pub mod public_work;
pub mod puzzle;
pub mod square_hole;
pub mod story;

View File

@@ -0,0 +1,16 @@
use axum::{Router, routing::get};
use crate::{
public_work::{get_public_work_detail, list_public_works},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/public-works", get(list_public_works))
.route(
"/api/public-works/{public_work_code}",
get(get_public_work_detail),
)
.with_state(state)
}

View File

@@ -0,0 +1,154 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::{HeaderName, StatusCode, header},
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::public_work::{
PublicWorkDetailEntryResponse, PublicWorkDetailResponse, PublicWorkGalleryEntryResponse,
PublicWorkGalleryResponse,
};
use spacetime_client::{
PublicWorkDetailEntryRecord, PublicWorkGalleryEntryRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState, work_author::resolve_work_author_by_user_id,
};
const PUBLIC_WORK_PROVIDER: &str = "public-work";
pub async fn list_public_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
public_work_error_response(&request_context, map_public_work_client_error(error))
})?
.into_iter()
.map(|entry| map_public_work_gallery_entry_response(&state, entry))
.collect::<Vec<_>>();
let total_count = items.len().min(u32::MAX as usize) as u32;
Ok(json_success_body(
Some(&request_context),
PublicWorkGalleryResponse {
items,
has_more: false,
next_cursor: None,
total_count,
},
))
}
pub async fn get_public_work_detail(
State(state): State<AppState>,
Path(public_work_code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if public_work_code.trim().is_empty() {
return Err(public_work_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUBLIC_WORK_PROVIDER,
"message": "publicWorkCode is required",
})),
));
}
let item = state
.spacetime_client()
.get_public_work_detail_by_code(public_work_code)
.await
.map_err(|error| {
public_work_error_response(&request_context, map_public_work_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
PublicWorkDetailResponse {
item: map_public_work_detail_entry_response(&state, item),
},
))
}
pub(crate) fn map_public_work_gallery_entry_response(
state: &AppState,
entry: PublicWorkGalleryEntryRecord,
) -> PublicWorkGalleryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
None,
);
PublicWorkGalleryEntryResponse {
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: 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,
recent_play_count_7d: entry.recent_play_count_7d,
published_at: entry.published_at,
updated_at: entry.updated_at,
sort_time_micros: entry.sort_time_micros,
}
}
pub(crate) fn map_public_work_detail_entry_response(
state: &AppState,
entry: PublicWorkDetailEntryRecord,
) -> PublicWorkDetailEntryResponse {
PublicWorkDetailEntryResponse {
entry: map_public_work_gallery_entry_response(state, entry.entry),
detail_payload_json: entry.detail_payload_json,
}
}
fn map_public_work_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn public_work_error_response(request_context: &RequestContext, error: AppError) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_static(PUBLIC_WORK_PROVIDER),
);
response
}

View File

@@ -1548,7 +1548,7 @@ pub async fn list_puzzle_gallery(
let rebuild_started_at = std::time::Instant::now();
let items = state
.spacetime_client()
.list_puzzle_gallery()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
puzzle_error_response(
@@ -1561,7 +1561,8 @@ pub async fn list_puzzle_gallery(
let response = build_puzzle_gallery_window_response(
items
.into_iter()
.map(|item| map_puzzle_gallery_card_response(&state, item))
.filter(|item| item.source_type == "puzzle")
.map(|item| map_public_work_puzzle_gallery_card_response(&state, item))
.collect(),
);
let cached_response = state
@@ -1919,6 +1920,7 @@ pub async fn advance_puzzle_next_level(
Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => {
AdvancePuzzleNextLevelRequest {
target_profile_id: None,
prefer_similar_work: false,
}
}
Err(error) => {
@@ -1939,6 +1941,7 @@ pub async fn advance_puzzle_next_level(
run_id,
owner_user_id: principal.subject().to_string(),
target_profile_id: payload.target_profile_id,
prefer_similar_work: payload.prefer_similar_work,
advanced_at_micros: current_utc_micros(),
})
.await

View File

@@ -439,6 +439,46 @@ pub(super) fn map_puzzle_gallery_card_response(
}
}
pub(super) fn map_public_work_puzzle_gallery_card_response(
state: &PuzzleApiState,
item: spacetime_client::PublicWorkGalleryEntryRecord,
) -> PuzzleWorkSummaryResponse {
let author = resolve_puzzle_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.world_name.clone(),
work_description: item.summary_text.clone(),
level_name: item.world_name,
summary: item.summary_text,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: "published".to_string(),
updated_at: item.updated_at,
published_at: item.published_at,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
point_incentive_total_points: 0.0,
point_incentive_claimable_points: 0,
publish_ready: true,
generation_status: Some("ready".to_string()),
levels: Vec::new(),
}
}
pub(super) fn map_puzzle_work_profile_response(
state: &PuzzleApiState,
item: PuzzleWorkProfileRecord,

View File

@@ -1805,6 +1805,18 @@ pub fn select_next_profiles<'a>(
available
}
pub fn select_runtime_next_profile<'a>(
same_work_next_profile: Option<&'a PuzzleWorkProfile>,
similar_work_profiles: &'a [&'a PuzzleWorkProfile],
prefer_similar_work: bool,
) -> Option<&'a PuzzleWorkProfile> {
if prefer_similar_work {
similar_work_profiles.first().copied().or(same_work_next_profile)
} else {
same_work_next_profile.or_else(|| similar_work_profiles.first().copied())
}
}
pub fn recommendation_score(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
@@ -3321,6 +3333,28 @@ mod tests {
assert_eq!(selected.profile_id, "b");
}
#[test]
fn select_runtime_next_profile_prefers_similar_work_when_requested() {
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]);
let similar_work_profiles = [&similar_work];
let selected = select_runtime_next_profile(
Some(&same_work),
&similar_work_profiles,
true,
)
.expect("should select similar work first");
assert_eq!(selected.profile_id, "similar");
}
#[test]
fn select_runtime_next_profile_falls_back_to_same_work_when_no_similar_candidate() {
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
let selected = select_runtime_next_profile(Some(&same_work), &[], true)
.expect("should fall back to same work");
assert_eq!(selected.profile_id, "same");
}
#[test]
fn restart_cleared_count_uses_selected_level_index() {
let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]);

View File

@@ -237,6 +237,8 @@ pub struct PuzzleRunNextLevelInput {
pub owner_user_id: String,
#[serde(default)]
pub target_profile_id: Option<String>,
#[serde(default)]
pub prefer_similar_work: bool,
pub advanced_at_micros: i64,
}

View File

@@ -17,6 +17,7 @@ pub mod llm;
pub mod match3d_agent;
pub mod match3d_runtime;
pub mod match3d_works;
pub mod public_work;
pub mod puzzle_agent;
pub mod puzzle_creative_template;
pub mod puzzle_gallery;

View File

@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
/// 公开作品列表统一契约。
///
/// 该契约面向 BFF 和后续可选前端直连订阅,字段只包含平台公开卡片需要的摘要信息。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkGalleryEntryResponse {
pub source_type: String,
pub work_id: String,
pub profile_id: String,
#[serde(default)]
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,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub cover_asset_id: Option<String>,
pub theme_tags: Vec<String>,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
#[serde(default)]
pub published_at: Option<String>,
pub updated_at: String,
pub sort_time_micros: i64,
}
/// 公开作品详情统一摘要契约。
///
/// `detail_payload_json` 只承载详情页展示扩展字段,不承载正式 runtime 配置。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkDetailEntryResponse {
#[serde(flatten)]
pub entry: PublicWorkGalleryEntryResponse,
pub detail_payload_json: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkGalleryResponse {
pub items: Vec<PublicWorkGalleryEntryResponse>,
#[serde(default)]
pub has_more: bool,
#[serde(default)]
pub next_cursor: Option<String>,
#[serde(default)]
pub total_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkDetailResponse {
pub item: PublicWorkDetailEntryResponse,
}

View File

@@ -29,6 +29,8 @@ pub struct DragPuzzlePieceRequest {
pub struct AdvancePuzzleNextLevelRequest {
#[serde(default)]
pub target_profile_id: Option<String>,
#[serde(default)]
pub prefer_similar_work: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -47,8 +47,8 @@ pub use mapper::{
Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput,
Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput,
Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput,
NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord,
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, PublicWorkDetailEntryRecord,
PublicWorkGalleryEntryRecord, PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
@@ -107,6 +107,7 @@ pub mod inventory;
pub mod jump_hop;
pub mod match3d;
pub mod npc;
pub mod public_work;
pub mod puzzle;
pub mod runtime;
pub mod square_hole;
@@ -570,6 +571,8 @@ impl SpacetimeClient {
) -> Result<Vec<SubscriptionHandle>, SpacetimeClientError> {
let mut subscriptions = Vec::new();
for query in [
"SELECT * FROM public_work_gallery_entry",
"SELECT * FROM public_work_detail_entry",
"SELECT * FROM bark_battle_gallery_view",
"SELECT * FROM puzzle_gallery_card_view",
"SELECT * FROM jump_hop_gallery_card_view",

View File

@@ -12,6 +12,7 @@ mod inventory;
mod jump_hop;
mod match3d;
mod npc;
mod public_work;
mod puzzle;
mod runtime;
mod runtime_profile;
@@ -94,6 +95,7 @@ pub use self::npc::{
CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord,
ResolveNpcBattleInteractionInput,
};
pub use self::public_work::{PublicWorkDetailEntryRecord, PublicWorkGalleryEntryRecord};
pub use self::puzzle::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
@@ -181,6 +183,9 @@ pub(crate) use self::npc::{
build_battle_state_record, map_battle_state_snapshot, map_inventory_item_source_kind,
map_npc_battle_interaction_procedure_result, validate_npc_battle_interaction_input,
};
pub(crate) use self::public_work::{
map_public_work_gallery_entry, map_public_work_gallery_entry_to_detail_entry,
};
pub(crate) use self::puzzle::{
map_puzzle_agent_session_procedure_result, map_puzzle_gallery_card_view_row,
map_puzzle_run_procedure_result, map_puzzle_work_procedure_result,

View File

@@ -0,0 +1,115 @@
use super::*;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicWorkGalleryEntryRecord {
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 recent_play_count_7d: u32,
pub published_at: Option<String>,
pub updated_at: String,
pub sort_time_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicWorkDetailEntryRecord {
pub entry: PublicWorkGalleryEntryRecord,
pub detail_payload_json: String,
}
pub(crate) fn map_public_work_gallery_entry(
snapshot: PublicWorkGalleryEntry,
recent_play_count_7d: u32,
) -> PublicWorkGalleryEntryRecord {
PublicWorkGalleryEntryRecord {
source_type: snapshot.source_type,
work_id: snapshot.work_id,
profile_id: snapshot.profile_id,
source_session_id: snapshot.source_session_id,
public_work_code: snapshot.public_work_code,
owner_user_id: snapshot.owner_user_id,
author_display_name: snapshot.author_display_name,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
cover_asset_id: snapshot.cover_asset_id,
theme_tags: snapshot.theme_tags,
play_count: snapshot.play_count,
remix_count: snapshot.remix_count,
like_count: snapshot.like_count,
recent_play_count_7d,
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
sort_time_micros: snapshot.sort_time_micros,
}
}
pub(crate) fn map_public_work_gallery_entry_to_detail_entry(
snapshot: PublicWorkDetailEntry,
recent_play_count_7d: u32,
) -> PublicWorkDetailEntryRecord {
let PublicWorkDetailEntry {
source_type,
work_id,
profile_id,
source_session_id,
public_work_code,
owner_user_id,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
cover_asset_id,
theme_tags,
play_count,
remix_count,
like_count,
published_at_micros,
updated_at_micros,
sort_time_micros,
detail_payload_json,
} = snapshot;
PublicWorkDetailEntryRecord {
entry: map_public_work_gallery_entry(
PublicWorkGalleryEntry {
source_type,
work_id,
profile_id,
source_session_id,
public_work_code,
owner_user_id,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
cover_asset_id,
theme_tags,
play_count,
remix_count,
like_count,
published_at_micros,
updated_at_micros,
sort_time_micros,
},
recent_play_count_7d,
),
detail_payload_json,
}
}

View File

@@ -758,6 +758,7 @@ pub struct PuzzleRunNextLevelRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub target_profile_id: Option<String>,
pub prefer_similar_work: bool,
pub advanced_at_micros: i64,
}

View File

@@ -560,6 +560,10 @@ pub mod profile_task_reward_claim_table;
pub mod profile_task_reward_claim_type;
pub mod profile_wallet_ledger_table;
pub mod profile_wallet_ledger_type;
pub mod public_work_detail_entry_table;
pub mod public_work_detail_entry_type;
pub mod public_work_gallery_entry_table;
pub mod public_work_gallery_entry_type;
pub mod public_work_like_table;
pub mod public_work_like_type;
pub mod public_work_play_daily_stat_table;
@@ -1591,6 +1595,10 @@ pub use profile_task_reward_claim_table::*;
pub use profile_task_reward_claim_type::ProfileTaskRewardClaim;
pub use profile_wallet_ledger_table::*;
pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use public_work_detail_entry_table::*;
pub use public_work_detail_entry_type::PublicWorkDetailEntry;
pub use public_work_gallery_entry_table::*;
pub use public_work_gallery_entry_type::PublicWorkGalleryEntry;
pub use public_work_like_table::*;
pub use public_work_like_type::PublicWorkLike;
pub use public_work_play_daily_stat_table::*;
@@ -2408,6 +2416,8 @@ pub struct DbUpdate {
profile_task_progress: __sdk::TableUpdate<ProfileTaskProgress>,
profile_task_reward_claim: __sdk::TableUpdate<ProfileTaskRewardClaim>,
profile_wallet_ledger: __sdk::TableUpdate<ProfileWalletLedger>,
public_work_detail_entry: __sdk::TableUpdate<PublicWorkDetailEntry>,
public_work_gallery_entry: __sdk::TableUpdate<PublicWorkGalleryEntry>,
public_work_like: __sdk::TableUpdate<PublicWorkLike>,
public_work_play_daily_stat: __sdk::TableUpdate<PublicWorkPlayDailyStat>,
puzzle_agent_message: __sdk::TableUpdate<PuzzleAgentMessageRow>,
@@ -2670,6 +2680,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(
profile_wallet_ledger_table::parse_table_update(table_update)?,
),
"public_work_detail_entry" => db_update.public_work_detail_entry.append(
public_work_detail_entry_table::parse_table_update(table_update)?,
),
"public_work_gallery_entry" => db_update.public_work_gallery_entry.append(
public_work_gallery_entry_table::parse_table_update(table_update)?,
),
"public_work_like" => db_update
.public_work_like
.append(public_work_like_table::parse_table_update(table_update)?),
@@ -3332,6 +3348,14 @@ impl __sdk::DbUpdate for DbUpdate {
"match_3_d_gallery_view",
&self.match_3_d_gallery_view,
);
diff.public_work_detail_entry = cache.apply_diff_to_table::<PublicWorkDetailEntry>(
"public_work_detail_entry",
&self.public_work_detail_entry,
);
diff.public_work_gallery_entry = cache.apply_diff_to_table::<PublicWorkGalleryEntry>(
"public_work_gallery_entry",
&self.public_work_gallery_entry,
);
diff.puzzle_gallery_card_view = cache.apply_diff_to_table::<PuzzleGalleryCardViewRow>(
"puzzle_gallery_card_view",
&self.puzzle_gallery_card_view,
@@ -3568,6 +3592,12 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_wallet_ledger" => db_update
.profile_wallet_ledger
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"public_work_detail_entry" => db_update
.public_work_detail_entry
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"public_work_gallery_entry" => db_update
.public_work_gallery_entry
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"public_work_like" => db_update
.public_work_like
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -3905,6 +3935,12 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_wallet_ledger" => db_update
.profile_wallet_ledger
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"public_work_detail_entry" => db_update
.public_work_detail_entry
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"public_work_gallery_entry" => db_update
.public_work_gallery_entry
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"public_work_like" => db_update
.public_work_like
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -4110,6 +4146,8 @@ pub struct AppliedDiff<'r> {
profile_task_progress: __sdk::TableAppliedDiff<'r, ProfileTaskProgress>,
profile_task_reward_claim: __sdk::TableAppliedDiff<'r, ProfileTaskRewardClaim>,
profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>,
public_work_detail_entry: __sdk::TableAppliedDiff<'r, PublicWorkDetailEntry>,
public_work_gallery_entry: __sdk::TableAppliedDiff<'r, PublicWorkGalleryEntry>,
public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>,
public_work_play_daily_stat: __sdk::TableAppliedDiff<'r, PublicWorkPlayDailyStat>,
puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>,
@@ -4492,6 +4530,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.profile_wallet_ledger,
event,
);
callbacks.invoke_table_row_callbacks::<PublicWorkDetailEntry>(
"public_work_detail_entry",
&self.public_work_detail_entry,
event,
);
callbacks.invoke_table_row_callbacks::<PublicWorkGalleryEntry>(
"public_work_gallery_entry",
&self.public_work_gallery_entry,
event,
);
callbacks.invoke_table_row_callbacks::<PublicWorkLike>(
"public_work_like",
&self.public_work_like,
@@ -5412,6 +5460,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
profile_task_progress_table::register_table(client_cache);
profile_task_reward_claim_table::register_table(client_cache);
profile_wallet_ledger_table::register_table(client_cache);
public_work_detail_entry_table::register_table(client_cache);
public_work_gallery_entry_table::register_table(client_cache);
public_work_like_table::register_table(client_cache);
public_work_play_daily_stat_table::register_table(client_cache);
puzzle_agent_message_table::register_table(client_cache);
@@ -5522,6 +5572,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
"profile_task_progress",
"profile_task_reward_claim",
"profile_wallet_ledger",
"public_work_detail_entry",
"public_work_gallery_entry",
"public_work_like",
"public_work_play_daily_stat",
"puzzle_agent_message",

View File

@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
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,
@@ -38,6 +39,7 @@ impl __sdk::InModule for BarkBattleGalleryViewRow {
pub struct BarkBattleGalleryViewRowCols {
pub work_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub owner_user_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub author_display_name: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub source_draft_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub config_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub ruleset_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
@@ -66,6 +68,10 @@ impl __sdk::__query_builder::HasCols for BarkBattleGalleryViewRow {
BarkBattleGalleryViewRowCols {
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
author_display_name: __sdk::__query_builder::Col::new(
table_name,
"author_display_name",
),
source_draft_id: __sdk::__query_builder::Col::new(table_name, "source_draft_id"),
config_version: __sdk::__query_builder::Col::new(table_name, "config_version"),
ruleset_version: __sdk::__query_builder::Col::new(table_name, "ruleset_version"),

View File

@@ -0,0 +1,114 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::public_work_detail_entry_type::PublicWorkDetailEntry;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `public_work_detail_entry`.
///
/// Obtain a handle from the [`PublicWorkDetailEntryTableAccess::public_work_detail_entry`] method on [`super::RemoteTables`],
/// like `ctx.db.public_work_detail_entry()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.public_work_detail_entry().on_insert(...)`.
pub struct PublicWorkDetailEntryTableHandle<'ctx> {
imp: __sdk::TableHandle<PublicWorkDetailEntry>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `public_work_detail_entry`.
///
/// Implemented for [`super::RemoteTables`].
pub trait PublicWorkDetailEntryTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`PublicWorkDetailEntryTableHandle`], which mediates access to the table `public_work_detail_entry`.
fn public_work_detail_entry(&self) -> PublicWorkDetailEntryTableHandle<'_>;
}
impl PublicWorkDetailEntryTableAccess for super::RemoteTables {
fn public_work_detail_entry(&self) -> PublicWorkDetailEntryTableHandle<'_> {
PublicWorkDetailEntryTableHandle {
imp: self
.imp
.get_table::<PublicWorkDetailEntry>("public_work_detail_entry"),
ctx: std::marker::PhantomData,
}
}
}
pub struct PublicWorkDetailEntryInsertCallbackId(__sdk::CallbackId);
pub struct PublicWorkDetailEntryDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for PublicWorkDetailEntryTableHandle<'ctx> {
type Row = PublicWorkDetailEntry;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = PublicWorkDetailEntry> + '_ {
self.imp.iter()
}
type InsertCallbackId = PublicWorkDetailEntryInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> PublicWorkDetailEntryInsertCallbackId {
PublicWorkDetailEntryInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: PublicWorkDetailEntryInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = PublicWorkDetailEntryDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> PublicWorkDetailEntryDeleteCallbackId {
PublicWorkDetailEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: PublicWorkDetailEntryDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<PublicWorkDetailEntry>("public_work_detail_entry");
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<PublicWorkDetailEntry>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<PublicWorkDetailEntry>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `PublicWorkDetailEntry`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait public_work_detail_entryQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `PublicWorkDetailEntry`.
fn public_work_detail_entry(&self) -> __sdk::__query_builder::Table<PublicWorkDetailEntry>;
}
impl public_work_detail_entryQueryTableAccess for __sdk::QueryTableAccessor {
fn public_work_detail_entry(&self) -> __sdk::__query_builder::Table<PublicWorkDetailEntry> {
__sdk::__query_builder::Table::new("public_work_detail_entry")
}
}

View File

@@ -0,0 +1,97 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
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,
}
impl __sdk::InModule for PublicWorkDetailEntry {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `PublicWorkDetailEntry`.
///
/// Provides typed access to columns for query building.
pub struct PublicWorkDetailEntryCols {
pub source_type: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub work_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub profile_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub source_session_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<String>>,
pub public_work_code: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub owner_user_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub author_display_name: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub world_name: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub subtitle: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub summary_text: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
pub cover_image_src: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<String>>,
pub cover_asset_id: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<String>>,
pub theme_tags: __sdk::__query_builder::Col<PublicWorkDetailEntry, Vec<String>>,
pub play_count: __sdk::__query_builder::Col<PublicWorkDetailEntry, u32>,
pub remix_count: __sdk::__query_builder::Col<PublicWorkDetailEntry, u32>,
pub like_count: __sdk::__query_builder::Col<PublicWorkDetailEntry, u32>,
pub published_at_micros: __sdk::__query_builder::Col<PublicWorkDetailEntry, Option<i64>>,
pub updated_at_micros: __sdk::__query_builder::Col<PublicWorkDetailEntry, i64>,
pub sort_time_micros: __sdk::__query_builder::Col<PublicWorkDetailEntry, i64>,
pub detail_payload_json: __sdk::__query_builder::Col<PublicWorkDetailEntry, String>,
}
impl __sdk::__query_builder::HasCols for PublicWorkDetailEntry {
type Cols = PublicWorkDetailEntryCols;
fn cols(table_name: &'static str) -> Self::Cols {
PublicWorkDetailEntryCols {
source_type: __sdk::__query_builder::Col::new(table_name, "source_type"),
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"),
public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
author_display_name: __sdk::__query_builder::Col::new(
table_name,
"author_display_name",
),
world_name: __sdk::__query_builder::Col::new(table_name, "world_name"),
subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"),
summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"),
cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"),
cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"),
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"),
like_count: __sdk::__query_builder::Col::new(table_name, "like_count"),
published_at_micros: __sdk::__query_builder::Col::new(
table_name,
"published_at_micros",
),
updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"),
sort_time_micros: __sdk::__query_builder::Col::new(table_name, "sort_time_micros"),
detail_payload_json: __sdk::__query_builder::Col::new(
table_name,
"detail_payload_json",
),
}
}
}

View File

@@ -0,0 +1,114 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::public_work_gallery_entry_type::PublicWorkGalleryEntry;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `public_work_gallery_entry`.
///
/// Obtain a handle from the [`PublicWorkGalleryEntryTableAccess::public_work_gallery_entry`] method on [`super::RemoteTables`],
/// like `ctx.db.public_work_gallery_entry()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.public_work_gallery_entry().on_insert(...)`.
pub struct PublicWorkGalleryEntryTableHandle<'ctx> {
imp: __sdk::TableHandle<PublicWorkGalleryEntry>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `public_work_gallery_entry`.
///
/// Implemented for [`super::RemoteTables`].
pub trait PublicWorkGalleryEntryTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`PublicWorkGalleryEntryTableHandle`], which mediates access to the table `public_work_gallery_entry`.
fn public_work_gallery_entry(&self) -> PublicWorkGalleryEntryTableHandle<'_>;
}
impl PublicWorkGalleryEntryTableAccess for super::RemoteTables {
fn public_work_gallery_entry(&self) -> PublicWorkGalleryEntryTableHandle<'_> {
PublicWorkGalleryEntryTableHandle {
imp: self
.imp
.get_table::<PublicWorkGalleryEntry>("public_work_gallery_entry"),
ctx: std::marker::PhantomData,
}
}
}
pub struct PublicWorkGalleryEntryInsertCallbackId(__sdk::CallbackId);
pub struct PublicWorkGalleryEntryDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for PublicWorkGalleryEntryTableHandle<'ctx> {
type Row = PublicWorkGalleryEntry;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = PublicWorkGalleryEntry> + '_ {
self.imp.iter()
}
type InsertCallbackId = PublicWorkGalleryEntryInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> PublicWorkGalleryEntryInsertCallbackId {
PublicWorkGalleryEntryInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: PublicWorkGalleryEntryInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = PublicWorkGalleryEntryDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> PublicWorkGalleryEntryDeleteCallbackId {
PublicWorkGalleryEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: PublicWorkGalleryEntryDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<PublicWorkGalleryEntry>("public_work_gallery_entry");
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<PublicWorkGalleryEntry>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<PublicWorkGalleryEntry>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `PublicWorkGalleryEntry`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait public_work_gallery_entryQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `PublicWorkGalleryEntry`.
fn public_work_gallery_entry(&self) -> __sdk::__query_builder::Table<PublicWorkGalleryEntry>;
}
impl public_work_gallery_entryQueryTableAccess for __sdk::QueryTableAccessor {
fn public_work_gallery_entry(&self) -> __sdk::__query_builder::Table<PublicWorkGalleryEntry> {
__sdk::__query_builder::Table::new("public_work_gallery_entry")
}
}

View File

@@ -0,0 +1,91 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
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,
}
impl __sdk::InModule for PublicWorkGalleryEntry {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `PublicWorkGalleryEntry`.
///
/// Provides typed access to columns for query building.
pub struct PublicWorkGalleryEntryCols {
pub source_type: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub work_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub profile_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub source_session_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<String>>,
pub public_work_code: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub owner_user_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub author_display_name: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub world_name: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub subtitle: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub summary_text: __sdk::__query_builder::Col<PublicWorkGalleryEntry, String>,
pub cover_image_src: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<String>>,
pub cover_asset_id: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<String>>,
pub theme_tags: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Vec<String>>,
pub play_count: __sdk::__query_builder::Col<PublicWorkGalleryEntry, u32>,
pub remix_count: __sdk::__query_builder::Col<PublicWorkGalleryEntry, u32>,
pub like_count: __sdk::__query_builder::Col<PublicWorkGalleryEntry, u32>,
pub published_at_micros: __sdk::__query_builder::Col<PublicWorkGalleryEntry, Option<i64>>,
pub updated_at_micros: __sdk::__query_builder::Col<PublicWorkGalleryEntry, i64>,
pub sort_time_micros: __sdk::__query_builder::Col<PublicWorkGalleryEntry, i64>,
}
impl __sdk::__query_builder::HasCols for PublicWorkGalleryEntry {
type Cols = PublicWorkGalleryEntryCols;
fn cols(table_name: &'static str) -> Self::Cols {
PublicWorkGalleryEntryCols {
source_type: __sdk::__query_builder::Col::new(table_name, "source_type"),
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"),
public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
author_display_name: __sdk::__query_builder::Col::new(
table_name,
"author_display_name",
),
world_name: __sdk::__query_builder::Col::new(table_name, "world_name"),
subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"),
summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"),
cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"),
cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"),
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"),
like_count: __sdk::__query_builder::Col::new(table_name, "like_count"),
published_at_micros: __sdk::__query_builder::Col::new(
table_name,
"published_at_micros",
),
updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"),
sort_time_micros: __sdk::__query_builder::Col::new(table_name, "sort_time_micros"),
}
}
}

View File

@@ -10,6 +10,7 @@ pub struct PuzzleRunNextLevelInput {
pub run_id: String,
pub owner_user_id: String,
pub target_profile_id: Option<String>,
pub prefer_similar_work: bool,
pub advanced_at_micros: i64,
}

View File

@@ -0,0 +1,171 @@
use super::*;
use crate::mapper::*;
impl SpacetimeClient {
pub async fn list_public_work_gallery_entries(
&self,
) -> Result<Vec<PublicWorkGalleryEntryRecord>, SpacetimeClientError> {
self.read_after_connect("list_public_work_gallery_entries", move |connection| {
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
let mut entries = connection
.db()
.public_work_gallery_entry()
.iter()
.collect::<Vec<_>>();
sort_public_work_gallery_entries(&mut entries);
Ok(entries
.into_iter()
.map(|entry| {
let recent_play_count_7d = recent_play_counts
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
.copied()
.unwrap_or(0);
map_public_work_gallery_entry(entry, recent_play_count_7d)
})
.collect())
})
.await
}
pub async fn list_public_work_detail_entries(
&self,
) -> Result<Vec<PublicWorkDetailEntryRecord>, SpacetimeClientError> {
self.read_after_connect("list_public_work_detail_entries", move |connection| {
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
let mut entries = connection
.db()
.public_work_detail_entry()
.iter()
.collect::<Vec<_>>();
sort_public_work_detail_entries(&mut entries);
Ok(entries
.into_iter()
.map(|entry| {
let recent_play_count_7d = recent_play_counts
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
.copied()
.unwrap_or(0);
map_public_work_gallery_entry_to_detail_entry(entry, recent_play_count_7d)
})
.collect())
})
.await
}
pub async fn get_public_work_detail_by_code(
&self,
public_work_code: String,
) -> Result<PublicWorkDetailEntryRecord, SpacetimeClientError> {
let public_work_code = public_work_code.trim().to_string();
if public_work_code.is_empty() {
return Err(SpacetimeClientError::validation_failed(
"publicWorkCode 不能为空",
));
}
self.read_after_connect("get_public_work_detail_by_code", move |connection| {
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
let entry = connection
.db()
.public_work_detail_entry()
.iter()
.find(|entry| {
entry
.public_work_code
.eq_ignore_ascii_case(&public_work_code)
})
.ok_or_else(|| SpacetimeClientError::Procedure("公开作品不存在".to_string()))?;
let recent_play_count_7d = recent_play_counts
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
.copied()
.unwrap_or(0);
Ok(map_public_work_gallery_entry_to_detail_entry(
entry,
recent_play_count_7d,
))
})
.await
}
pub async fn get_public_work_detail_by_source_profile(
&self,
source_type: String,
profile_id: String,
) -> Result<PublicWorkDetailEntryRecord, SpacetimeClientError> {
let source_type = source_type.trim().to_string();
let profile_id = profile_id.trim().to_string();
if source_type.is_empty() || profile_id.is_empty() {
return Err(SpacetimeClientError::validation_failed(
"sourceType 和 profileId 不能为空",
));
}
self.read_after_connect(
"get_public_work_detail_by_source_profile",
move |connection| {
let recent_play_counts = public_work_recent_play_counts_by_source(connection);
let entry = connection
.db()
.public_work_detail_entry()
.iter()
.find(|entry| {
entry.source_type == source_type && entry.profile_id == profile_id
})
.ok_or_else(|| SpacetimeClientError::Procedure("公开作品不存在".to_string()))?;
let recent_play_count_7d = recent_play_counts
.get(&(entry.source_type.clone(), entry.profile_id.clone()))
.copied()
.unwrap_or(0);
Ok(map_public_work_gallery_entry_to_detail_entry(
entry,
recent_play_count_7d,
))
},
)
.await
}
}
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 sort_public_work_detail_entries(entries: &mut [PublicWorkDetailEntry]) {
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 public_work_recent_play_counts_by_source(
connection: &DbConnection,
) -> HashMap<(String, String), u32> {
let current_day = current_public_work_day();
let first_day = current_day - (PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
let mut counts = HashMap::new();
for row in connection.db().public_work_play_daily_stat().iter() {
if row.played_day < first_day || row.played_day > current_day {
continue;
}
let entry = counts
.entry((row.source_type, row.profile_id))
.or_insert(0_u32);
*entry = entry.saturating_add(row.play_count);
}
counts
}

View File

@@ -640,6 +640,7 @@ impl SpacetimeClient {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
target_profile_id: input.target_profile_id,
prefer_similar_work: input.prefer_similar_work,
advanced_at_micros: input.advanced_at_micros,
};

View File

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

View File

@@ -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,

View File

@@ -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_")

View File

@@ -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::*;

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

View File

@@ -2154,51 +2154,54 @@ fn advance_puzzle_next_level_tx(
let same_work_next_profile =
selected_profile_level_after_runtime_level(&current_profile, current_level)
.map(|level| profile_for_single_level(&current_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(
&current_profile,
&current_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(
&current_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(
&current_run,
next_profile,
micros_to_millis(input.advanced_at_micros),