1648 lines
57 KiB
Rust
1648 lines
57 KiB
Rust
pub(crate) mod tables;
|
|
mod types;
|
|
|
|
pub use tables::*;
|
|
pub use types::*;
|
|
|
|
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json};
|
|
use module_puzzle_clear::{
|
|
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck,
|
|
PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level,
|
|
apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout,
|
|
parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs,
|
|
retry_puzzle_clear_level, start_puzzle_clear_run,
|
|
};
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
use spacetimedb::AnonymousViewContext;
|
|
use std::collections::BTreeMap;
|
|
|
|
#[spacetimedb::view(accessor = puzzle_clear_gallery_view, public)]
|
|
pub fn puzzle_clear_gallery_view(ctx: &AnonymousViewContext) -> Vec<PuzzleClearGalleryViewRow> {
|
|
let mut items = ctx
|
|
.db
|
|
.puzzle_clear_work_profile()
|
|
.by_puzzle_clear_work_publication_status()
|
|
.filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED)
|
|
.filter(|row| row.visible)
|
|
.filter_map(|row| match build_gallery_view_row(&row) {
|
|
Ok(item) => Some(item),
|
|
Err(error) => {
|
|
log::warn!(
|
|
"拼消消公开广场 view 跳过损坏作品 profile_id={}: {}",
|
|
row.profile_id,
|
|
error
|
|
);
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
items.sort_by(|left, right| {
|
|
right
|
|
.updated_at_micros
|
|
.cmp(&left.updated_at_micros)
|
|
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
|
});
|
|
items
|
|
}
|
|
|
|
#[spacetimedb::view(accessor = puzzle_clear_gallery_card_view, public)]
|
|
pub fn puzzle_clear_gallery_card_view(
|
|
ctx: &AnonymousViewContext,
|
|
) -> Vec<PuzzleClearGalleryCardViewRow> {
|
|
puzzle_clear_gallery_view(ctx)
|
|
.into_iter()
|
|
.map(|row| PuzzleClearGalleryCardViewRow {
|
|
public_work_code: build_puzzle_clear_public_work_code(&row.profile_id),
|
|
work_id: row.work_id,
|
|
profile_id: row.profile_id,
|
|
owner_user_id: row.owner_user_id,
|
|
author_display_name: row.author_display_name,
|
|
work_title: row.work_title,
|
|
work_description: row.work_description,
|
|
theme_prompt: row.theme_prompt,
|
|
cover_image_src: row.cover_image_src,
|
|
publication_status: row.publication_status,
|
|
play_count: row.play_count,
|
|
updated_at_micros: row.updated_at_micros,
|
|
published_at_micros: row.published_at_micros,
|
|
generation_status: row.generation_status,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
|
pub struct PuzzleClearGalleryViewRow {
|
|
pub work_id: String,
|
|
pub profile_id: String,
|
|
pub owner_user_id: String,
|
|
pub source_session_id: String,
|
|
pub author_display_name: String,
|
|
pub work_title: String,
|
|
pub work_description: String,
|
|
pub theme_prompt: String,
|
|
pub generate_board_background: bool,
|
|
pub board_background_asset: Option<PuzzleClearImageAssetSnapshot>,
|
|
pub board_background_prompt: String,
|
|
pub card_back_image_src: Option<String>,
|
|
pub atlas_asset: PuzzleClearImageAssetSnapshot,
|
|
pub pattern_groups: Vec<PuzzleClearPatternGroupSnapshot>,
|
|
pub card_assets: Vec<PuzzleClearCardAssetSnapshot>,
|
|
pub cover_image_src: Option<String>,
|
|
pub publication_status: String,
|
|
pub publish_ready: bool,
|
|
pub play_count: u32,
|
|
pub generation_status: String,
|
|
pub updated_at_micros: i64,
|
|
pub published_at_micros: Option<i64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct PuzzleClearGalleryCardViewRow {
|
|
pub public_work_code: String,
|
|
pub work_id: String,
|
|
pub profile_id: String,
|
|
pub owner_user_id: String,
|
|
pub author_display_name: String,
|
|
pub work_title: String,
|
|
pub work_description: String,
|
|
pub theme_prompt: String,
|
|
pub cover_image_src: Option<String>,
|
|
pub publication_status: String,
|
|
pub play_count: u32,
|
|
pub updated_at_micros: i64,
|
|
pub published_at_micros: Option<i64>,
|
|
pub generation_status: String,
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn create_puzzle_clear_agent_session(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearAgentSessionCreateInput,
|
|
) -> PuzzleClearAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| create_puzzle_clear_agent_session_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_puzzle_clear_agent_session(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearAgentSessionGetInput,
|
|
) -> PuzzleClearAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| get_puzzle_clear_agent_session_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn compile_puzzle_clear_draft(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearDraftCompileInput,
|
|
) -> PuzzleClearAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| compile_puzzle_clear_draft_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_puzzle_clear_work_profile(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearWorkGetInput,
|
|
) -> PuzzleClearWorkProcedureResult {
|
|
match ctx.try_with_tx(|tx| get_puzzle_clear_work_profile_tx(tx, input.clone())) {
|
|
Ok(work) => work_result(work),
|
|
Err(message) => work_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn update_puzzle_clear_work(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearWorkUpdateInput,
|
|
) -> PuzzleClearWorkProcedureResult {
|
|
match ctx.try_with_tx(|tx| update_puzzle_clear_work_tx(tx, input.clone())) {
|
|
Ok(work) => work_result(work),
|
|
Err(message) => work_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn publish_puzzle_clear_work(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearWorkPublishInput,
|
|
) -> PuzzleClearWorkProcedureResult {
|
|
match ctx.try_with_tx(|tx| publish_puzzle_clear_work_tx(tx, input.clone())) {
|
|
Ok(work) => work_result(work),
|
|
Err(message) => work_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn list_puzzle_clear_works(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearWorksListInput,
|
|
) -> PuzzleClearWorksProcedureResult {
|
|
match ctx.try_with_tx(|tx| list_puzzle_clear_works_tx(tx, input.clone())) {
|
|
Ok(items) => PuzzleClearWorksProcedureResult {
|
|
ok: true,
|
|
items,
|
|
error_message: None,
|
|
},
|
|
Err(message) => PuzzleClearWorksProcedureResult {
|
|
ok: false,
|
|
items: Vec::new(),
|
|
error_message: Some(message),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn start_puzzle_clear_runtime_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearRunStartInput,
|
|
) -> PuzzleClearRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| start_puzzle_clear_runtime_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_puzzle_clear_runtime_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearRunGetInput,
|
|
) -> PuzzleClearRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| get_puzzle_clear_runtime_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn swap_puzzle_clear_cards(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearRunSwapInput,
|
|
) -> PuzzleClearRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| swap_puzzle_clear_cards_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn retry_puzzle_clear_level_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearRunRetryLevelInput,
|
|
) -> PuzzleClearRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| retry_puzzle_clear_level_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn advance_puzzle_clear_next_level(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearRunNextLevelInput,
|
|
) -> PuzzleClearRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| advance_puzzle_clear_next_level_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn mark_puzzle_clear_level_time_up(
|
|
ctx: &mut ProcedureContext,
|
|
input: PuzzleClearRunTimeUpInput,
|
|
) -> PuzzleClearRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| mark_puzzle_clear_level_time_up_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
fn create_puzzle_clear_agent_session_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearAgentSessionCreateInput,
|
|
) -> Result<PuzzleClearAgentSessionSnapshot, String> {
|
|
require_non_empty(&input.session_id, "puzzle_clear session_id")?;
|
|
require_non_empty(&input.owner_user_id, "puzzle_clear owner_user_id")?;
|
|
require_non_empty(&input.work_title, "work_title")?;
|
|
require_non_empty(&input.theme_prompt, "theme_prompt")?;
|
|
if ctx
|
|
.db
|
|
.puzzle_clear_agent_session()
|
|
.session_id()
|
|
.find(&input.session_id)
|
|
.is_some()
|
|
{
|
|
return Err("puzzle_clear_agent_session.session_id 已存在".to_string());
|
|
}
|
|
|
|
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
|
|
let draft = PuzzleClearDraftSnapshot {
|
|
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
|
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
profile_id: None,
|
|
work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME),
|
|
work_description: input.work_description.trim().to_string(),
|
|
theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME),
|
|
generate_board_background: input.generate_board_background,
|
|
board_background_asset: input
|
|
.board_background_asset_json
|
|
.as_deref()
|
|
.map(parse_json)
|
|
.transpose()?,
|
|
board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt),
|
|
card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
|
|
atlas_asset: None,
|
|
pattern_groups: Vec::new(),
|
|
card_assets: Vec::new(),
|
|
generation_status: PUZZLE_CLEAR_GENERATION_DRAFT.to_string(),
|
|
};
|
|
let owner_user_id = input.owner_user_id.clone();
|
|
let session_id = input.session_id.clone();
|
|
ctx.db
|
|
.puzzle_clear_agent_session()
|
|
.insert(PuzzleClearAgentSessionRow {
|
|
session_id: input.session_id.clone(),
|
|
owner_user_id: input.owner_user_id,
|
|
status: PUZZLE_CLEAR_GENERATION_DRAFT.to_string(),
|
|
draft_json: to_json_string(&draft),
|
|
published_profile_id: String::new(),
|
|
created_at,
|
|
updated_at: created_at,
|
|
});
|
|
|
|
get_puzzle_clear_agent_session_tx(
|
|
ctx,
|
|
PuzzleClearAgentSessionGetInput {
|
|
session_id,
|
|
owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn get_puzzle_clear_agent_session_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearAgentSessionGetInput,
|
|
) -> Result<PuzzleClearAgentSessionSnapshot, String> {
|
|
let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
|
build_session_snapshot(&row)
|
|
}
|
|
|
|
fn compile_puzzle_clear_draft_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearDraftCompileInput,
|
|
) -> Result<PuzzleClearAgentSessionSnapshot, String> {
|
|
require_non_empty(&input.profile_id, "puzzle_clear profile_id")?;
|
|
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
|
if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) {
|
|
return mark_puzzle_clear_generation_failed_tx(ctx, input, session);
|
|
}
|
|
let pattern_groups: Vec<PuzzleClearPatternGroupSnapshot> = input
|
|
.pattern_groups_json
|
|
.as_deref()
|
|
.map(parse_json)
|
|
.transpose()?
|
|
.ok_or_else(|| "puzzle_clear pattern_groups 缺少真实生成资产".to_string())?;
|
|
let atlas_asset: PuzzleClearImageAssetSnapshot = input
|
|
.atlas_asset_json
|
|
.as_deref()
|
|
.map(parse_json)
|
|
.transpose()?
|
|
.ok_or_else(|| "puzzle_clear atlas_asset 缺少真实生成资产".to_string())?;
|
|
let card_assets: Vec<PuzzleClearCardAssetSnapshot> = input
|
|
.card_assets_json
|
|
.as_deref()
|
|
.map(parse_json)
|
|
.transpose()?
|
|
.ok_or_else(|| "puzzle_clear card_assets 缺少真实生成资产".to_string())?;
|
|
if card_assets.is_empty() {
|
|
return Err("puzzle_clear card_assets 不能为空".to_string());
|
|
}
|
|
if !is_real_puzzle_clear_asset(
|
|
atlas_asset.asset_object_id.as_str(),
|
|
atlas_asset.image_object_key.as_str(),
|
|
atlas_asset.image_src.as_str(),
|
|
) {
|
|
return Err("puzzle_clear atlas_asset 缺少真实生成资产".to_string());
|
|
}
|
|
if card_assets.iter().any(|asset| {
|
|
!is_real_puzzle_clear_asset(
|
|
asset.asset_object_id.as_str(),
|
|
asset.image_object_key.as_str(),
|
|
asset.image_src.as_str(),
|
|
)
|
|
}) {
|
|
return Err("puzzle_clear card_assets 缺少真实生成资产".to_string());
|
|
}
|
|
let board_background_asset = input
|
|
.board_background_asset_json
|
|
.as_deref()
|
|
.map(parse_json)
|
|
.transpose()?;
|
|
let generation_status = input
|
|
.generation_status
|
|
.clone()
|
|
.unwrap_or_else(|| PUZZLE_CLEAR_GENERATION_READY.to_string());
|
|
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
|
let draft = PuzzleClearDraftSnapshot {
|
|
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
|
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
profile_id: Some(input.profile_id.clone()),
|
|
work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME),
|
|
work_description: input.work_description.trim().to_string(),
|
|
theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME),
|
|
generate_board_background: input.generate_board_background,
|
|
board_background_asset: board_background_asset.clone(),
|
|
board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt),
|
|
card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
|
|
atlas_asset: Some(atlas_asset.clone()),
|
|
pattern_groups: pattern_groups.clone(),
|
|
card_assets: card_assets.clone(),
|
|
generation_status: generation_status.clone(),
|
|
};
|
|
let row = PuzzleClearWorkProfileRow {
|
|
profile_id: input.profile_id.clone(),
|
|
work_id: input.profile_id.clone(),
|
|
owner_user_id: input.owner_user_id.clone(),
|
|
source_session_id: input.session_id.clone(),
|
|
author_display_name: clean_string(&input.author_display_name, "拼消消玩家"),
|
|
work_title: draft.work_title.clone(),
|
|
work_description: draft.work_description.clone(),
|
|
theme_prompt: draft.theme_prompt.clone(),
|
|
generate_board_background: draft.generate_board_background,
|
|
board_background_asset_json: board_background_asset
|
|
.as_ref()
|
|
.map(to_json_string)
|
|
.unwrap_or_default(),
|
|
board_background_prompt: clean_optional(&draft.board_background_prompt),
|
|
card_back_image_src: PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string(),
|
|
atlas_asset_json: to_json_string(&atlas_asset),
|
|
pattern_groups_json: to_json_string(&pattern_groups),
|
|
card_assets_json: to_json_string(&card_assets),
|
|
cover_image_src: cover_image_src(&board_background_asset, &atlas_asset),
|
|
generation_status,
|
|
publication_status: PUZZLE_CLEAR_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 0,
|
|
updated_at: compiled_at,
|
|
published_at: None,
|
|
visible: true,
|
|
};
|
|
upsert_work(ctx, row);
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
PuzzleClearAgentSessionRow {
|
|
status: draft.generation_status.clone(),
|
|
draft_json: to_json_string(&draft),
|
|
published_profile_id: input.profile_id,
|
|
updated_at: compiled_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
|
|
get_puzzle_clear_agent_session_tx(
|
|
ctx,
|
|
PuzzleClearAgentSessionGetInput {
|
|
session_id: input.session_id,
|
|
owner_user_id: input.owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn mark_puzzle_clear_generation_failed_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearDraftCompileInput,
|
|
session: PuzzleClearAgentSessionRow,
|
|
) -> Result<PuzzleClearAgentSessionSnapshot, String> {
|
|
let failed_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
|
let mut draft = if session.draft_json.trim().is_empty() {
|
|
None
|
|
} else {
|
|
parse_json::<PuzzleClearDraftSnapshot>(&session.draft_json).ok()
|
|
}
|
|
.unwrap_or_else(|| PuzzleClearDraftSnapshot {
|
|
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
|
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
profile_id: Some(input.profile_id.clone()),
|
|
work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME),
|
|
work_description: input.work_description.trim().to_string(),
|
|
theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME),
|
|
generate_board_background: input.generate_board_background,
|
|
board_background_asset: input
|
|
.board_background_asset_json
|
|
.as_deref()
|
|
.and_then(|json| parse_json::<PuzzleClearImageAssetSnapshot>(json).ok()),
|
|
board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt),
|
|
card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
|
|
atlas_asset: None,
|
|
pattern_groups: Vec::new(),
|
|
card_assets: Vec::new(),
|
|
generation_status: PUZZLE_CLEAR_GENERATION_FAILED.to_string(),
|
|
});
|
|
draft.profile_id = Some(input.profile_id.clone());
|
|
draft.work_title = clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME);
|
|
draft.work_description = input.work_description.trim().to_string();
|
|
draft.theme_prompt = clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME);
|
|
draft.generate_board_background = input.generate_board_background;
|
|
draft.board_background_prompt =
|
|
clean_string(&input.board_background_prompt, &input.theme_prompt);
|
|
let existing_board_background_asset = draft.board_background_asset.take();
|
|
draft.board_background_asset = input
|
|
.board_background_asset_json
|
|
.as_deref()
|
|
.map(parse_json)
|
|
.transpose()?
|
|
.or(existing_board_background_asset);
|
|
draft.generation_status = PUZZLE_CLEAR_GENERATION_FAILED.to_string();
|
|
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
PuzzleClearAgentSessionRow {
|
|
status: PUZZLE_CLEAR_GENERATION_FAILED.to_string(),
|
|
draft_json: to_json_string(&draft),
|
|
updated_at: failed_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
|
|
get_puzzle_clear_agent_session_tx(
|
|
ctx,
|
|
PuzzleClearAgentSessionGetInput {
|
|
session_id: input.session_id,
|
|
owner_user_id: input.owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn get_puzzle_clear_work_profile_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearWorkGetInput,
|
|
) -> Result<PuzzleClearWorkSnapshot, String> {
|
|
let row = find_work(ctx, &input.profile_id)?;
|
|
if !input.owner_user_id.trim().is_empty() && row.owner_user_id != input.owner_user_id {
|
|
return Err("无权访问该 puzzle_clear work".to_string());
|
|
}
|
|
build_work_snapshot(&row)
|
|
}
|
|
|
|
fn update_puzzle_clear_work_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearWorkUpdateInput,
|
|
) -> Result<PuzzleClearWorkSnapshot, String> {
|
|
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
|
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
|
let board_background_asset = input
|
|
.board_background_asset_json
|
|
.as_deref()
|
|
.map(parse_json::<PuzzleClearImageAssetSnapshot>)
|
|
.transpose()?;
|
|
let atlas_asset = parse_json::<PuzzleClearImageAssetSnapshot>(&row.atlas_asset_json)?;
|
|
let mut next = clone_work(&row);
|
|
next.work_title = clean_string(&input.work_title, &row.work_title);
|
|
next.work_description = input.work_description.trim().to_string();
|
|
next.theme_prompt = clean_string(&input.theme_prompt, &row.theme_prompt);
|
|
next.generate_board_background = input.generate_board_background;
|
|
let next_board_background_prompt =
|
|
clean_string(&input.board_background_prompt, &input.theme_prompt);
|
|
next.board_background_prompt = clean_optional(&next_board_background_prompt);
|
|
next.board_background_asset_json = board_background_asset
|
|
.as_ref()
|
|
.map(to_json_string)
|
|
.unwrap_or_default();
|
|
next.cover_image_src = cover_image_src(&board_background_asset, &atlas_asset);
|
|
next.updated_at = updated_at;
|
|
replace_work(ctx, &row, next);
|
|
let updated = find_work(ctx, &row.profile_id)?;
|
|
sync_session_from_work(ctx, &updated, updated_at)?;
|
|
build_work_snapshot(&updated)
|
|
}
|
|
|
|
fn publish_puzzle_clear_work_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearWorkPublishInput,
|
|
) -> Result<PuzzleClearWorkSnapshot, String> {
|
|
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
|
let snapshot = build_work_snapshot(&row)?;
|
|
if !snapshot.publish_ready {
|
|
return Err("拼消消发布需要 atlas、切片卡牌和标题齐备".to_string());
|
|
}
|
|
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
|
|
replace_work(
|
|
ctx,
|
|
&row,
|
|
PuzzleClearWorkProfileRow {
|
|
publication_status: PUZZLE_CLEAR_PUBLICATION_PUBLISHED.to_string(),
|
|
updated_at: published_at,
|
|
published_at: Some(published_at),
|
|
..clone_work(&row)
|
|
},
|
|
);
|
|
if let Some(session) = ctx
|
|
.db
|
|
.puzzle_clear_agent_session()
|
|
.session_id()
|
|
.find(&row.source_session_id)
|
|
{
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
PuzzleClearAgentSessionRow {
|
|
status: PUZZLE_CLEAR_GENERATION_READY.to_string(),
|
|
updated_at: published_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
}
|
|
let updated = find_work(ctx, &row.profile_id)?;
|
|
build_work_snapshot(&updated)
|
|
}
|
|
|
|
fn list_puzzle_clear_works_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearWorksListInput,
|
|
) -> Result<Vec<PuzzleClearWorkSnapshot>, String> {
|
|
let mut rows = if input.owner_user_id.trim().is_empty() {
|
|
ctx.db
|
|
.puzzle_clear_work_profile()
|
|
.iter()
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
ctx.db
|
|
.puzzle_clear_work_profile()
|
|
.by_puzzle_clear_work_owner_user_id()
|
|
.filter(input.owner_user_id.as_str())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
if input.published_only {
|
|
rows.retain(|row| row.publication_status == PUZZLE_CLEAR_PUBLICATION_PUBLISHED);
|
|
}
|
|
rows.sort_by(|left, right| {
|
|
right
|
|
.updated_at
|
|
.cmp(&left.updated_at)
|
|
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
|
});
|
|
rows.into_iter()
|
|
.map(|row| build_work_snapshot(&row))
|
|
.collect()
|
|
}
|
|
|
|
fn start_puzzle_clear_runtime_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearRunStartInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
require_non_empty(&input.run_id, "puzzle_clear run_id")?;
|
|
let work = find_work(ctx, &input.profile_id)?;
|
|
if work.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED {
|
|
return Err("拼消消 runtime 只能启动已发布作品".to_string());
|
|
}
|
|
let cards = domain_cards_from_work(&work)?;
|
|
let (board, deck) = build_level_board_and_deck(1, &work.profile_id, &cards)?;
|
|
let domain_run = start_puzzle_clear_run(
|
|
input.run_id.clone(),
|
|
input.owner_user_id.clone(),
|
|
input.profile_id.clone(),
|
|
board,
|
|
deck,
|
|
input.started_at_ms.max(0) as u64,
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
upsert_run(ctx, &domain_run, input.started_at_ms);
|
|
increment_work_play_count(ctx, &work, input.started_at_ms);
|
|
insert_event(
|
|
ctx,
|
|
input.client_event_id,
|
|
input.owner_user_id.clone(),
|
|
input.profile_id,
|
|
input.run_id,
|
|
PUZZLE_CLEAR_EVENT_RUN_STARTED,
|
|
None,
|
|
input.started_at_ms,
|
|
);
|
|
build_runtime_snapshot(&domain_run)
|
|
}
|
|
|
|
fn get_puzzle_clear_runtime_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearRunGetInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = parse_json::<DomainRunSnapshot>(&row.snapshot_json)?;
|
|
build_runtime_snapshot(&snapshot)
|
|
}
|
|
|
|
fn swap_puzzle_clear_cards_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearRunSwapInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = parse_json::<DomainRunSnapshot>(&row.snapshot_json)?;
|
|
let next = apply_puzzle_clear_swap(
|
|
&snapshot,
|
|
PuzzleClearMove {
|
|
from_row: input.from_row,
|
|
from_col: input.from_col,
|
|
to_row: input.to_row,
|
|
to_col: input.to_col,
|
|
},
|
|
input.swapped_at_ms.max(0) as u64,
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
replace_run(ctx, &row, &next, input.swapped_at_ms);
|
|
insert_event(
|
|
ctx,
|
|
input.client_action_id,
|
|
input.owner_user_id.clone(),
|
|
next.profile_id.clone(),
|
|
input.run_id,
|
|
PUZZLE_CLEAR_EVENT_SWAP,
|
|
Some(runtime_event_result(&snapshot, &next, input.swapped_at_ms)),
|
|
input.swapped_at_ms,
|
|
);
|
|
insert_terminal_runtime_event_if_needed(
|
|
ctx,
|
|
&snapshot,
|
|
&next,
|
|
input.owner_user_id,
|
|
input.swapped_at_ms,
|
|
);
|
|
build_runtime_snapshot(&next)
|
|
}
|
|
|
|
fn retry_puzzle_clear_level_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearRunRetryLevelInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = parse_json::<DomainRunSnapshot>(&row.snapshot_json)?;
|
|
let work = find_work(ctx, &snapshot.profile_id)?;
|
|
let cards = domain_cards_from_work(&work)?;
|
|
let (board, deck) = build_level_board_and_deck(
|
|
snapshot.level_index,
|
|
&format!("{}-retry-{}", snapshot.profile_id, input.restarted_at_ms),
|
|
&cards,
|
|
)?;
|
|
let next =
|
|
retry_puzzle_clear_level(&snapshot, board, deck, input.restarted_at_ms.max(0) as u64)
|
|
.map_err(|error| error.to_string())?;
|
|
replace_run(ctx, &row, &next, input.restarted_at_ms);
|
|
insert_event(
|
|
ctx,
|
|
input.client_action_id,
|
|
input.owner_user_id.clone(),
|
|
next.profile_id.clone(),
|
|
input.run_id,
|
|
PUZZLE_CLEAR_EVENT_RETRY_LEVEL,
|
|
None,
|
|
input.restarted_at_ms,
|
|
);
|
|
build_runtime_snapshot(&next)
|
|
}
|
|
|
|
fn advance_puzzle_clear_next_level_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearRunNextLevelInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = parse_json::<DomainRunSnapshot>(&row.snapshot_json)?;
|
|
let next_level = snapshot.level_index.saturating_add(1);
|
|
let work = find_work(ctx, &snapshot.profile_id)?;
|
|
let cards = domain_cards_from_work(&work)?;
|
|
let (board, deck) = build_level_board_and_deck(
|
|
next_level,
|
|
&format!("{}-level-{next_level}", snapshot.profile_id),
|
|
&cards,
|
|
)?;
|
|
let next =
|
|
advance_puzzle_clear_level(&snapshot, board, deck, input.started_at_ms.max(0) as u64)
|
|
.map_err(|error| error.to_string())?;
|
|
replace_run(ctx, &row, &next, input.started_at_ms);
|
|
insert_event(
|
|
ctx,
|
|
input.client_action_id,
|
|
input.owner_user_id.clone(),
|
|
next.profile_id.clone(),
|
|
input.run_id,
|
|
PUZZLE_CLEAR_EVENT_NEXT_LEVEL,
|
|
Some(next.level_index.to_string()),
|
|
input.started_at_ms,
|
|
);
|
|
build_runtime_snapshot(&next)
|
|
}
|
|
|
|
fn mark_puzzle_clear_level_time_up_tx(
|
|
ctx: &ReducerContext,
|
|
input: PuzzleClearRunTimeUpInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = parse_json::<DomainRunSnapshot>(&row.snapshot_json)?;
|
|
let next = fail_puzzle_clear_level_on_timeout(&snapshot, input.occurred_at_ms.max(0) as u64)
|
|
.map_err(|error| error.to_string())?;
|
|
replace_run(ctx, &row, &next, input.occurred_at_ms);
|
|
insert_event(
|
|
ctx,
|
|
input.client_action_id,
|
|
input.owner_user_id.clone(),
|
|
next.profile_id.clone(),
|
|
input.run_id,
|
|
PUZZLE_CLEAR_EVENT_TIME_UP,
|
|
Some(runtime_event_result(&snapshot, &next, input.occurred_at_ms)),
|
|
input.occurred_at_ms,
|
|
);
|
|
insert_terminal_runtime_event_if_needed(
|
|
ctx,
|
|
&snapshot,
|
|
&next,
|
|
input.owner_user_id,
|
|
input.occurred_at_ms,
|
|
);
|
|
build_runtime_snapshot(&next)
|
|
}
|
|
|
|
fn build_gallery_view_row(
|
|
row: &PuzzleClearWorkProfileRow,
|
|
) -> Result<PuzzleClearGalleryViewRow, String> {
|
|
let work = build_work_snapshot(row)?;
|
|
Ok(PuzzleClearGalleryViewRow {
|
|
work_id: work.work_id,
|
|
profile_id: work.profile_id,
|
|
owner_user_id: work.owner_user_id,
|
|
source_session_id: work.source_session_id,
|
|
author_display_name: work.author_display_name,
|
|
work_title: work.work_title,
|
|
work_description: work.work_description,
|
|
theme_prompt: work.theme_prompt,
|
|
generate_board_background: work.generate_board_background,
|
|
board_background_asset: work.board_background_asset,
|
|
board_background_prompt: work.board_background_prompt,
|
|
card_back_image_src: work.card_back_image_src,
|
|
atlas_asset: work.atlas_asset,
|
|
pattern_groups: work.pattern_groups,
|
|
card_assets: work.card_assets,
|
|
cover_image_src: work.cover_image_src,
|
|
publication_status: work.publication_status,
|
|
publish_ready: work.publish_ready,
|
|
play_count: work.play_count,
|
|
generation_status: work.generation_status,
|
|
updated_at_micros: work.updated_at_micros,
|
|
published_at_micros: work.published_at_micros,
|
|
})
|
|
}
|
|
|
|
pub fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
|
|
let normalized = profile_id
|
|
.chars()
|
|
.filter(|character| character.is_ascii_alphanumeric())
|
|
.flat_map(|character| character.to_uppercase())
|
|
.collect::<String>();
|
|
let suffix_source = if normalized.is_empty() {
|
|
"00000000".to_string()
|
|
} else {
|
|
normalized
|
|
};
|
|
let suffix = if suffix_source.len() > 8 {
|
|
suffix_source[suffix_source.len() - 8..].to_string()
|
|
} else {
|
|
format!("{suffix_source:0>8}")
|
|
};
|
|
format!("PC-{suffix}")
|
|
}
|
|
|
|
fn build_session_snapshot(
|
|
row: &PuzzleClearAgentSessionRow,
|
|
) -> Result<PuzzleClearAgentSessionSnapshot, String> {
|
|
Ok(PuzzleClearAgentSessionSnapshot {
|
|
session_id: row.session_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
status: row.status.clone(),
|
|
draft: clean_optional(&row.draft_json)
|
|
.map(|value| parse_json(&value))
|
|
.transpose()?,
|
|
published_profile_id: clean_optional(&row.published_profile_id),
|
|
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
|
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
|
})
|
|
}
|
|
|
|
fn build_work_snapshot(row: &PuzzleClearWorkProfileRow) -> Result<PuzzleClearWorkSnapshot, String> {
|
|
let atlas_asset = parse_json(&row.atlas_asset_json)?;
|
|
let card_assets = parse_json_or_default(&row.card_assets_json);
|
|
let pattern_groups = parse_json_or_default(&row.pattern_groups_json);
|
|
Ok(PuzzleClearWorkSnapshot {
|
|
work_id: row.work_id.clone(),
|
|
profile_id: row.profile_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
source_session_id: row.source_session_id.clone(),
|
|
author_display_name: row.author_display_name.clone(),
|
|
work_title: row.work_title.clone(),
|
|
work_description: row.work_description.clone(),
|
|
theme_prompt: row.theme_prompt.clone(),
|
|
generate_board_background: row.generate_board_background,
|
|
board_background_asset: clean_optional(&row.board_background_asset_json)
|
|
.map(|value| parse_json(&value))
|
|
.transpose()?,
|
|
board_background_prompt: row
|
|
.board_background_prompt
|
|
.as_deref()
|
|
.and_then(clean_optional)
|
|
.unwrap_or_default(),
|
|
card_back_image_src: clean_optional(&row.card_back_image_src),
|
|
atlas_asset,
|
|
pattern_groups,
|
|
card_assets,
|
|
cover_image_src: clean_optional(&row.cover_image_src),
|
|
publication_status: row.publication_status.clone(),
|
|
publish_ready: is_publish_ready(row),
|
|
play_count: row.play_count,
|
|
generation_status: row.generation_status.clone(),
|
|
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
|
published_at_micros: row
|
|
.published_at
|
|
.map(|value| value.to_micros_since_unix_epoch()),
|
|
})
|
|
}
|
|
|
|
fn sync_session_from_work(
|
|
ctx: &ReducerContext,
|
|
work: &PuzzleClearWorkProfileRow,
|
|
updated_at: Timestamp,
|
|
) -> Result<(), String> {
|
|
let Some(session) = ctx
|
|
.db
|
|
.puzzle_clear_agent_session()
|
|
.session_id()
|
|
.find(&work.source_session_id)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let draft = PuzzleClearDraftSnapshot {
|
|
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
|
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
profile_id: Some(work.profile_id.clone()),
|
|
work_title: work.work_title.clone(),
|
|
work_description: work.work_description.clone(),
|
|
theme_prompt: work.theme_prompt.clone(),
|
|
generate_board_background: work.generate_board_background,
|
|
board_background_asset: clean_optional(&work.board_background_asset_json)
|
|
.map(|value| parse_json(&value))
|
|
.transpose()?,
|
|
board_background_prompt: work
|
|
.board_background_prompt
|
|
.as_deref()
|
|
.and_then(clean_optional)
|
|
.unwrap_or_default(),
|
|
card_back_image_src: clean_optional(&work.card_back_image_src),
|
|
atlas_asset: Some(parse_json(&work.atlas_asset_json)?),
|
|
pattern_groups: parse_json_or_default(&work.pattern_groups_json),
|
|
card_assets: parse_json_or_default(&work.card_assets_json),
|
|
generation_status: work.generation_status.clone(),
|
|
};
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
PuzzleClearAgentSessionRow {
|
|
status: work.generation_status.clone(),
|
|
draft_json: to_json_string(&draft),
|
|
updated_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn build_level_board_and_deck(
|
|
level_index: u32,
|
|
seed: &str,
|
|
all_cards: &[PuzzleClearCard],
|
|
) -> Result<(PuzzleClearBoard, PuzzleClearDeck), String> {
|
|
let level = puzzle_clear_level_configs()
|
|
.into_iter()
|
|
.find(|config| config.level_index == level_index)
|
|
.ok_or_else(|| "拼消消关卡不存在".to_string())?;
|
|
let allowed = ordered_level_cards(
|
|
all_cards
|
|
.iter()
|
|
.filter(|card| level.unlocked_shapes.contains(&card.shape))
|
|
.cloned()
|
|
.collect::<Vec<_>>(),
|
|
seed,
|
|
level.target_clears as usize,
|
|
);
|
|
let board = create_puzzle_clear_board(&level, seed, allowed.clone())
|
|
.map_err(|error| error.to_string())?;
|
|
let board_total = (level.board_size * level.board_size) as usize;
|
|
let mut ready_columns = vec![Vec::new(); level.board_size as usize];
|
|
for (index, card) in allowed.into_iter().skip(board_total).enumerate() {
|
|
ready_columns[index % level.board_size as usize].push(card);
|
|
}
|
|
Ok((board, PuzzleClearDeck { ready_columns }))
|
|
}
|
|
|
|
fn ordered_level_cards(
|
|
cards: Vec<PuzzleClearCard>,
|
|
seed: &str,
|
|
target_groups: usize,
|
|
) -> Vec<PuzzleClearCard> {
|
|
let mut groups: BTreeMap<String, Vec<PuzzleClearCard>> = BTreeMap::new();
|
|
for card in cards {
|
|
groups.entry(card.group_id.clone()).or_default().push(card);
|
|
}
|
|
let mut grouped = groups.into_values().collect::<Vec<_>>();
|
|
grouped.sort_by(|left, right| {
|
|
let left_key = left
|
|
.first()
|
|
.map(|card| stable_level_group_key(seed, &card.group_id))
|
|
.unwrap_or_default();
|
|
let right_key = right
|
|
.first()
|
|
.map(|card| stable_level_group_key(seed, &card.group_id))
|
|
.unwrap_or_default();
|
|
left_key.cmp(&right_key)
|
|
});
|
|
grouped
|
|
.into_iter()
|
|
.take(target_groups)
|
|
.flat_map(|mut group| {
|
|
group.sort_by_key(|card| (card.part_y, card.part_x));
|
|
group
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn stable_level_group_key(seed: &str, group_id: &str) -> u64 {
|
|
let mut state = 0xcbf2_9ce4_8422_2325u64;
|
|
for byte in seed.bytes().chain(group_id.bytes()) {
|
|
state ^= u64::from(byte);
|
|
state = state.wrapping_mul(0x1000_0000_01b3);
|
|
}
|
|
state
|
|
}
|
|
|
|
fn domain_cards_from_work(row: &PuzzleClearWorkProfileRow) -> Result<Vec<PuzzleClearCard>, String> {
|
|
let cards = parse_json::<Vec<PuzzleClearCardAssetSnapshot>>(&row.card_assets_json)?;
|
|
Ok(cards
|
|
.into_iter()
|
|
.map(|card| PuzzleClearCard {
|
|
card_id: card.card_id,
|
|
group_id: card.group_id,
|
|
shape: parse_puzzle_clear_shape_kind(&card.shape),
|
|
orientation: parse_puzzle_clear_orientation(&card.orientation),
|
|
part_x: card.part_x,
|
|
part_y: card.part_y,
|
|
image_src: card.image_src,
|
|
image_object_key: card.image_object_key,
|
|
asset_object_id: card.asset_object_id,
|
|
source_atlas_cell: card.source_atlas_cell,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn build_runtime_snapshot(
|
|
snapshot: &DomainRunSnapshot,
|
|
) -> Result<PuzzleClearRuntimeSnapshot, String> {
|
|
let level = puzzle_clear_level_configs()
|
|
.into_iter()
|
|
.find(|config| config.level_index == snapshot.level_index)
|
|
.ok_or_else(|| "拼消消 runtime 关卡不存在".to_string())?;
|
|
Ok(PuzzleClearRuntimeSnapshot {
|
|
run_id: snapshot.run_id.clone(),
|
|
profile_id: snapshot.profile_id.clone(),
|
|
owner_user_id: snapshot.owner_user_id.clone(),
|
|
status: snapshot.status.as_str().to_string(),
|
|
level_index: snapshot.level_index,
|
|
clears_done: snapshot.clears_done,
|
|
target_clears: level.target_clears,
|
|
level_duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS,
|
|
level_started_at_ms: snapshot.level_started_at_ms,
|
|
board: PuzzleClearBoardSnapshot {
|
|
rows: snapshot.board.rows,
|
|
cols: snapshot.board.cols,
|
|
cells: snapshot
|
|
.board
|
|
.cells
|
|
.iter()
|
|
.map(|cell| PuzzleClearBoardCellSnapshot {
|
|
row: cell.row,
|
|
col: cell.col,
|
|
card: cell.card.as_ref().map(card_asset_from_domain),
|
|
locked_group_id: cell.locked_group_id.clone(),
|
|
})
|
|
.collect(),
|
|
},
|
|
ready_columns: snapshot
|
|
.deck
|
|
.ready_columns
|
|
.iter()
|
|
.map(|column| column.iter().map(card_asset_from_domain).collect())
|
|
.collect(),
|
|
started_at_ms: snapshot.started_at_ms,
|
|
finished_at_ms: snapshot.finished_at_ms,
|
|
})
|
|
}
|
|
|
|
fn card_asset_from_domain(card: &PuzzleClearCard) -> PuzzleClearCardAssetSnapshot {
|
|
PuzzleClearCardAssetSnapshot {
|
|
card_id: card.card_id.clone(),
|
|
group_id: card.group_id.clone(),
|
|
shape: card.shape.as_str().to_string(),
|
|
orientation: card.orientation.as_str().to_string(),
|
|
part_x: card.part_x,
|
|
part_y: card.part_y,
|
|
image_src: card.image_src.clone(),
|
|
image_object_key: card.image_object_key.clone(),
|
|
asset_object_id: card.asset_object_id.clone(),
|
|
source_atlas_cell: card.source_atlas_cell.clone(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn default_pattern_groups() -> Vec<PuzzleClearPatternGroupSnapshot> {
|
|
module_puzzle_clear::plan_puzzle_clear_pattern_groups(128)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|group| PuzzleClearPatternGroupSnapshot {
|
|
group_id: group.group_id,
|
|
shape: group.shape.as_str().to_string(),
|
|
width: group.width,
|
|
height: group.height,
|
|
atlas_x: group.atlas_x,
|
|
atlas_y: group.atlas_y,
|
|
atlas_width: group.atlas_width,
|
|
atlas_height: group.atlas_height,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn default_atlas_asset(profile_id: &str, prompt: &str) -> PuzzleClearImageAssetSnapshot {
|
|
PuzzleClearImageAssetSnapshot {
|
|
asset_id: format!("{profile_id}-atlas"),
|
|
image_src: format!("/generated-puzzle-clear-assets/{profile_id}/atlas.png"),
|
|
image_object_key: format!("generated-puzzle-clear-assets/{profile_id}/atlas.png"),
|
|
asset_object_id: format!("{profile_id}-atlas-object"),
|
|
generation_provider: "deterministic-placeholder".to_string(),
|
|
prompt: prompt.to_string(),
|
|
width: 3072,
|
|
height: 3072,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn default_card_assets(
|
|
profile_id: &str,
|
|
groups: &[PuzzleClearPatternGroupSnapshot],
|
|
) -> Vec<PuzzleClearCardAssetSnapshot> {
|
|
let domain_groups = groups
|
|
.iter()
|
|
.map(|group| module_puzzle_clear::PuzzleClearPatternGroup {
|
|
group_id: group.group_id.clone(),
|
|
shape: parse_puzzle_clear_shape_kind(&group.shape),
|
|
width: group.width,
|
|
height: group.height,
|
|
atlas_x: group.atlas_x,
|
|
atlas_y: group.atlas_y,
|
|
atlas_width: group.atlas_width,
|
|
atlas_height: group.atlas_height,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
module_puzzle_clear::build_cards_from_groups(
|
|
&domain_groups,
|
|
&format!("/generated-puzzle-clear-assets/{profile_id}/cards"),
|
|
)
|
|
.into_iter()
|
|
.map(|card| card_asset_from_domain(&card))
|
|
.collect()
|
|
}
|
|
|
|
fn cover_image_src(
|
|
board_background_asset: &Option<PuzzleClearImageAssetSnapshot>,
|
|
atlas_asset: &PuzzleClearImageAssetSnapshot,
|
|
) -> String {
|
|
board_background_asset
|
|
.as_ref()
|
|
.map(|asset| asset.image_src.clone())
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| atlas_asset.image_src.clone())
|
|
}
|
|
|
|
fn find_owned_session(
|
|
ctx: &ReducerContext,
|
|
session_id: &str,
|
|
owner_user_id: &str,
|
|
) -> Result<PuzzleClearAgentSessionRow, String> {
|
|
let row = ctx
|
|
.db
|
|
.puzzle_clear_agent_session()
|
|
.session_id()
|
|
.find(&session_id.to_string())
|
|
.ok_or_else(|| "puzzle_clear_agent_session 不存在".to_string())?;
|
|
if row.owner_user_id != owner_user_id {
|
|
return Err("无权访问该 puzzle_clear session".to_string());
|
|
}
|
|
Ok(row)
|
|
}
|
|
|
|
fn find_work(ctx: &ReducerContext, profile_id: &str) -> Result<PuzzleClearWorkProfileRow, String> {
|
|
ctx.db
|
|
.puzzle_clear_work_profile()
|
|
.profile_id()
|
|
.find(&profile_id.to_string())
|
|
.ok_or_else(|| "puzzle_clear_work_profile 不存在".to_string())
|
|
}
|
|
|
|
fn find_owned_work(
|
|
ctx: &ReducerContext,
|
|
profile_id: &str,
|
|
owner_user_id: &str,
|
|
) -> Result<PuzzleClearWorkProfileRow, String> {
|
|
let row = find_work(ctx, profile_id)?;
|
|
if row.owner_user_id != owner_user_id {
|
|
return Err("无权访问该 puzzle_clear work".to_string());
|
|
}
|
|
Ok(row)
|
|
}
|
|
|
|
fn find_owned_run(
|
|
ctx: &ReducerContext,
|
|
run_id: &str,
|
|
owner_user_id: &str,
|
|
) -> Result<PuzzleClearRuntimeRunRow, String> {
|
|
let row = ctx
|
|
.db
|
|
.puzzle_clear_runtime_run()
|
|
.run_id()
|
|
.find(&run_id.to_string())
|
|
.ok_or_else(|| "puzzle_clear_runtime_run 不存在".to_string())?;
|
|
if row.owner_user_id != owner_user_id {
|
|
return Err("无权访问该 puzzle_clear run".to_string());
|
|
}
|
|
Ok(row)
|
|
}
|
|
|
|
fn upsert_work(ctx: &ReducerContext, row: PuzzleClearWorkProfileRow) {
|
|
if let Some(old) = ctx
|
|
.db
|
|
.puzzle_clear_work_profile()
|
|
.profile_id()
|
|
.find(&row.profile_id)
|
|
{
|
|
ctx.db.puzzle_clear_work_profile().delete(old);
|
|
}
|
|
ctx.db.puzzle_clear_work_profile().insert(row);
|
|
}
|
|
|
|
fn replace_work(
|
|
ctx: &ReducerContext,
|
|
old: &PuzzleClearWorkProfileRow,
|
|
next: PuzzleClearWorkProfileRow,
|
|
) {
|
|
ctx.db.puzzle_clear_work_profile().delete(clone_work(old));
|
|
ctx.db.puzzle_clear_work_profile().insert(next);
|
|
}
|
|
|
|
fn replace_session(
|
|
ctx: &ReducerContext,
|
|
old: &PuzzleClearAgentSessionRow,
|
|
next: PuzzleClearAgentSessionRow,
|
|
) {
|
|
ctx.db
|
|
.puzzle_clear_agent_session()
|
|
.delete(clone_session(old));
|
|
ctx.db.puzzle_clear_agent_session().insert(next);
|
|
}
|
|
|
|
fn upsert_run(ctx: &ReducerContext, snapshot: &DomainRunSnapshot, updated_at_ms: i64) {
|
|
if let Some(old) = ctx
|
|
.db
|
|
.puzzle_clear_runtime_run()
|
|
.run_id()
|
|
.find(&snapshot.run_id)
|
|
{
|
|
ctx.db.puzzle_clear_runtime_run().delete(old);
|
|
}
|
|
let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000));
|
|
ctx.db
|
|
.puzzle_clear_runtime_run()
|
|
.insert(run_row_from_snapshot(snapshot, created_at, created_at));
|
|
}
|
|
|
|
fn replace_run(
|
|
ctx: &ReducerContext,
|
|
old: &PuzzleClearRuntimeRunRow,
|
|
snapshot: &DomainRunSnapshot,
|
|
updated_at_ms: i64,
|
|
) {
|
|
ctx.db.puzzle_clear_runtime_run().delete(clone_run(old));
|
|
ctx.db
|
|
.puzzle_clear_runtime_run()
|
|
.insert(run_row_from_snapshot(
|
|
snapshot,
|
|
old.created_at,
|
|
Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)),
|
|
));
|
|
}
|
|
|
|
fn run_row_from_snapshot(
|
|
snapshot: &DomainRunSnapshot,
|
|
created_at: Timestamp,
|
|
updated_at: Timestamp,
|
|
) -> PuzzleClearRuntimeRunRow {
|
|
PuzzleClearRuntimeRunRow {
|
|
run_id: snapshot.run_id.clone(),
|
|
owner_user_id: snapshot.owner_user_id.clone(),
|
|
profile_id: snapshot.profile_id.clone(),
|
|
status: snapshot.status.as_str().to_string(),
|
|
level_index: snapshot.level_index,
|
|
clears_done: snapshot.clears_done,
|
|
snapshot_json: to_json_string(snapshot),
|
|
started_at_ms: snapshot.started_at_ms as i64,
|
|
finished_at_ms: snapshot
|
|
.finished_at_ms
|
|
.map(|value| value as i64)
|
|
.unwrap_or(0),
|
|
created_at,
|
|
updated_at,
|
|
}
|
|
}
|
|
|
|
fn increment_work_play_count(
|
|
ctx: &ReducerContext,
|
|
row: &PuzzleClearWorkProfileRow,
|
|
played_at_ms: i64,
|
|
) {
|
|
replace_work(
|
|
ctx,
|
|
row,
|
|
PuzzleClearWorkProfileRow {
|
|
play_count: row.play_count.saturating_add(1),
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(played_at_ms.saturating_mul(1000)),
|
|
..clone_work(row)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn insert_event(
|
|
ctx: &ReducerContext,
|
|
event_id: String,
|
|
owner_user_id: String,
|
|
profile_id: String,
|
|
run_id: String,
|
|
event_type: &str,
|
|
result: Option<String>,
|
|
occurred_at_ms: i64,
|
|
) {
|
|
let event_id = clean_optional(&event_id).unwrap_or_else(|| {
|
|
format!(
|
|
"puzzle-clear-event-{}-{}-{}",
|
|
run_id, event_type, occurred_at_ms
|
|
)
|
|
});
|
|
if ctx
|
|
.db
|
|
.puzzle_clear_event()
|
|
.event_id()
|
|
.find(&event_id)
|
|
.is_some()
|
|
{
|
|
return;
|
|
}
|
|
ctx.db.puzzle_clear_event().insert(PuzzleClearEventRow {
|
|
event_id,
|
|
owner_user_id,
|
|
profile_id,
|
|
run_id,
|
|
event_type: event_type.to_string(),
|
|
result: result.unwrap_or_default(),
|
|
occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_ms.saturating_mul(1000)),
|
|
});
|
|
}
|
|
|
|
fn insert_terminal_runtime_event_if_needed(
|
|
ctx: &ReducerContext,
|
|
previous: &DomainRunSnapshot,
|
|
next: &DomainRunSnapshot,
|
|
owner_user_id: String,
|
|
occurred_at_ms: i64,
|
|
) {
|
|
if previous.status == next.status {
|
|
return;
|
|
}
|
|
let event_type = match next.status {
|
|
module_puzzle_clear::PuzzleClearRunStatus::LevelCleared => {
|
|
Some(PUZZLE_CLEAR_EVENT_LEVEL_COMPLETED)
|
|
}
|
|
module_puzzle_clear::PuzzleClearRunStatus::Finished => {
|
|
Some(PUZZLE_CLEAR_EVENT_RUN_FINISHED)
|
|
}
|
|
module_puzzle_clear::PuzzleClearRunStatus::LevelFailed => {
|
|
Some(PUZZLE_CLEAR_EVENT_LEVEL_FAILED)
|
|
}
|
|
module_puzzle_clear::PuzzleClearRunStatus::Playing => None,
|
|
};
|
|
let Some(event_type) = event_type else {
|
|
return;
|
|
};
|
|
insert_event(
|
|
ctx,
|
|
format!("{}:{}:{}", next.run_id, event_type, next.level_index),
|
|
owner_user_id,
|
|
next.profile_id.clone(),
|
|
next.run_id.clone(),
|
|
event_type,
|
|
Some(runtime_event_result(previous, next, occurred_at_ms)),
|
|
occurred_at_ms,
|
|
);
|
|
}
|
|
|
|
fn runtime_event_result(
|
|
previous: &DomainRunSnapshot,
|
|
next: &DomainRunSnapshot,
|
|
occurred_at_ms: i64,
|
|
) -> String {
|
|
let elapsed_ms = occurred_at_ms
|
|
.max(0)
|
|
.saturating_sub(next.level_started_at_ms as i64);
|
|
json!({
|
|
"status": next.status.as_str(),
|
|
"levelIndex": next.level_index,
|
|
"clearsDone": next.clears_done,
|
|
"clearDelta": next.clears_done.saturating_sub(previous.clears_done),
|
|
"elapsedMs": elapsed_ms,
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
fn is_publish_ready(row: &PuzzleClearWorkProfileRow) -> bool {
|
|
!row.work_title.trim().is_empty()
|
|
&& !row.atlas_asset_json.trim().is_empty()
|
|
&& !row.pattern_groups_json.trim().is_empty()
|
|
&& !row.card_assets_json.trim().is_empty()
|
|
&& row.generation_status == PUZZLE_CLEAR_GENERATION_READY
|
|
&& parse_json::<PuzzleClearImageAssetSnapshot>(&row.atlas_asset_json)
|
|
.map(|asset| {
|
|
is_real_puzzle_clear_asset(
|
|
asset.asset_object_id.as_str(),
|
|
asset.image_object_key.as_str(),
|
|
asset.image_src.as_str(),
|
|
)
|
|
})
|
|
.unwrap_or(false)
|
|
&& parse_json::<Vec<PuzzleClearCardAssetSnapshot>>(&row.card_assets_json)
|
|
.map(|assets| {
|
|
!assets.is_empty()
|
|
&& assets.iter().all(|asset| {
|
|
is_real_puzzle_clear_asset(
|
|
asset.asset_object_id.as_str(),
|
|
asset.image_object_key.as_str(),
|
|
asset.image_src.as_str(),
|
|
)
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn is_real_puzzle_clear_asset(
|
|
asset_object_id: &str,
|
|
image_object_key: &str,
|
|
image_src: &str,
|
|
) -> bool {
|
|
asset_object_id.starts_with("assetobj_")
|
|
&& image_object_key.starts_with("generated-puzzle-clear-assets/")
|
|
&& image_src.starts_with("/generated-puzzle-clear-assets/")
|
|
}
|
|
|
|
fn require_non_empty(value: &str, label: &str) -> Result<(), String> {
|
|
if value.trim().is_empty() {
|
|
Err(format!("{label} 不能为空"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn clean_optional(value: &str) -> Option<String> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value.to_string())
|
|
}
|
|
}
|
|
|
|
fn clean_string(value: &str, fallback: &str) -> String {
|
|
clean_optional(value).unwrap_or_else(|| fallback.to_string())
|
|
}
|
|
|
|
fn parse_json<T>(value: &str) -> Result<T, String>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
serde_json::from_str(value).map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn parse_json_or_default<T>(value: &str) -> T
|
|
where
|
|
T: DeserializeOwned + Default,
|
|
{
|
|
serde_json::from_str(value).unwrap_or_default()
|
|
}
|
|
|
|
fn to_json_string<T>(value: &T) -> String
|
|
where
|
|
T: Serialize,
|
|
{
|
|
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
|
|
}
|
|
|
|
fn session_result(
|
|
session: PuzzleClearAgentSessionSnapshot,
|
|
) -> PuzzleClearAgentSessionProcedureResult {
|
|
PuzzleClearAgentSessionProcedureResult {
|
|
ok: true,
|
|
session: Some(session),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn session_error(message: String) -> PuzzleClearAgentSessionProcedureResult {
|
|
PuzzleClearAgentSessionProcedureResult {
|
|
ok: false,
|
|
session: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
fn work_result(work: PuzzleClearWorkSnapshot) -> PuzzleClearWorkProcedureResult {
|
|
PuzzleClearWorkProcedureResult {
|
|
ok: true,
|
|
work: Some(work),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn work_error(message: String) -> PuzzleClearWorkProcedureResult {
|
|
PuzzleClearWorkProcedureResult {
|
|
ok: false,
|
|
work: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
fn run_result(run: PuzzleClearRuntimeSnapshot) -> PuzzleClearRunProcedureResult {
|
|
PuzzleClearRunProcedureResult {
|
|
ok: true,
|
|
run: Some(run),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn run_error(message: String) -> PuzzleClearRunProcedureResult {
|
|
PuzzleClearRunProcedureResult {
|
|
ok: false,
|
|
run: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
fn clone_session(row: &PuzzleClearAgentSessionRow) -> PuzzleClearAgentSessionRow {
|
|
PuzzleClearAgentSessionRow {
|
|
session_id: row.session_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
status: row.status.clone(),
|
|
draft_json: row.draft_json.clone(),
|
|
published_profile_id: row.published_profile_id.clone(),
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
}
|
|
}
|
|
|
|
fn clone_work(row: &PuzzleClearWorkProfileRow) -> PuzzleClearWorkProfileRow {
|
|
PuzzleClearWorkProfileRow {
|
|
profile_id: row.profile_id.clone(),
|
|
work_id: row.work_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
source_session_id: row.source_session_id.clone(),
|
|
author_display_name: row.author_display_name.clone(),
|
|
work_title: row.work_title.clone(),
|
|
work_description: row.work_description.clone(),
|
|
theme_prompt: row.theme_prompt.clone(),
|
|
generate_board_background: row.generate_board_background,
|
|
board_background_asset_json: row.board_background_asset_json.clone(),
|
|
board_background_prompt: row.board_background_prompt.clone(),
|
|
card_back_image_src: row.card_back_image_src.clone(),
|
|
atlas_asset_json: row.atlas_asset_json.clone(),
|
|
pattern_groups_json: row.pattern_groups_json.clone(),
|
|
card_assets_json: row.card_assets_json.clone(),
|
|
cover_image_src: row.cover_image_src.clone(),
|
|
generation_status: row.generation_status.clone(),
|
|
publication_status: row.publication_status.clone(),
|
|
play_count: row.play_count,
|
|
updated_at: row.updated_at,
|
|
published_at: row.published_at,
|
|
visible: row.visible,
|
|
}
|
|
}
|
|
|
|
fn clone_run(row: &PuzzleClearRuntimeRunRow) -> PuzzleClearRuntimeRunRow {
|
|
PuzzleClearRuntimeRunRow {
|
|
run_id: row.run_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
profile_id: row.profile_id.clone(),
|
|
status: row.status.clone(),
|
|
level_index: row.level_index,
|
|
clears_done: row.clears_done,
|
|
snapshot_json: row.snapshot_json.clone(),
|
|
started_at_ms: row.started_at_ms,
|
|
finished_at_ms: row.finished_at_ms,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn puzzle_clear_publish_ready_rejects_placeholder_assets() {
|
|
let now = Timestamp::from_micros_since_unix_epoch(1_780_000_000_000_000);
|
|
let groups = default_pattern_groups();
|
|
let atlas = default_atlas_asset("puzzle-clear-profile-placeholder", "占位主题");
|
|
let cards = default_card_assets("puzzle-clear-profile-placeholder", &groups);
|
|
let row = PuzzleClearWorkProfileRow {
|
|
profile_id: "puzzle-clear-profile-placeholder".to_string(),
|
|
work_id: "puzzle-clear-profile-placeholder".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_session_id: "puzzle-clear-session-placeholder".to_string(),
|
|
author_display_name: "拼消消玩家".to_string(),
|
|
work_title: "占位拼消消".to_string(),
|
|
work_description: String::new(),
|
|
theme_prompt: "占位主题".to_string(),
|
|
generate_board_background: false,
|
|
board_background_asset_json: String::new(),
|
|
board_background_prompt: None,
|
|
card_back_image_src: PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string(),
|
|
atlas_asset_json: to_json_string(&atlas),
|
|
pattern_groups_json: to_json_string(&groups),
|
|
card_assets_json: to_json_string(&cards),
|
|
cover_image_src: atlas.image_src,
|
|
generation_status: PUZZLE_CLEAR_GENERATION_READY.to_string(),
|
|
publication_status: PUZZLE_CLEAR_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 0,
|
|
updated_at: now,
|
|
published_at: None,
|
|
visible: true,
|
|
};
|
|
|
|
assert!(!is_publish_ready(&row));
|
|
}
|
|
}
|