fix: settle puzzle failures and profile tasks
This commit is contained in:
@@ -55,17 +55,18 @@ use spacetime_client::{
|
||||
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
|
||||
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
|
||||
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
|
||||
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
|
||||
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
|
||||
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
|
||||
PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
|
||||
@@ -606,6 +606,36 @@ pub async fn execute_puzzle_agent_action(
|
||||
),
|
||||
"拼图 Agent action 开始执行"
|
||||
);
|
||||
|
||||
let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| {
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let error_message = error.body_text();
|
||||
let session_id = compile_session_id.to_string();
|
||||
let log_session_id = session_id.clone();
|
||||
let log_owner_user_id = owner_user_id.clone();
|
||||
async move {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
error_message,
|
||||
failed_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %log_session_id,
|
||||
owner_user_id = %log_owner_user_id,
|
||||
message = %error,
|
||||
"拼图草稿失败态回写失败,继续返回原始错误"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
@@ -666,10 +696,18 @@ pub async fn execute_puzzle_agent_action(
|
||||
now,
|
||||
)
|
||||
.await
|
||||
};
|
||||
let session = match session {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) => {
|
||||
mark_puzzle_compile_failure(&error, &compile_session_id).await;
|
||||
Err(puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
error,
|
||||
))
|
||||
}
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
};
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"首关拼图草稿",
|
||||
|
||||
@@ -388,6 +388,22 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
|
||||
draft
|
||||
}
|
||||
|
||||
pub fn mark_failed_puzzle_result_draft_generation(
|
||||
mut draft: PuzzleResultDraft,
|
||||
) -> PuzzleResultDraft {
|
||||
if draft.levels.is_empty() {
|
||||
draft = normalize_puzzle_draft(draft);
|
||||
}
|
||||
for level in &mut draft.levels {
|
||||
if level.generation_status.trim() != "ready" {
|
||||
level.generation_status = "failed".to_string();
|
||||
}
|
||||
}
|
||||
sync_primary_level_fields(&mut draft);
|
||||
draft.generation_status = "failed".to_string();
|
||||
draft
|
||||
}
|
||||
|
||||
pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) {
|
||||
if let Some(primary_level) = draft.levels.first() {
|
||||
draft.level_name = primary_level.level_name.clone();
|
||||
@@ -3212,6 +3228,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_generation_marks_pending_levels_failed_without_touching_ready_assets() {
|
||||
let anchor_pack = infer_anchor_pack("雨夜猫街", Some("雨夜猫街"));
|
||||
let mut draft = compile_result_draft(&anchor_pack, &[]);
|
||||
draft.generation_status = "generating".to_string();
|
||||
draft.levels[0].generation_status = "generating".to_string();
|
||||
draft.levels.push(PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-2".to_string(),
|
||||
level_name: "第二关".to_string(),
|
||||
picture_description: "第二关画面".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![PuzzleGeneratedImageCandidate {
|
||||
candidate_id: "candidate-1".to_string(),
|
||||
image_src: "/ready.png".to_string(),
|
||||
asset_id: "asset-1".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
actual_prompt: None,
|
||||
source_type: "generated".to_string(),
|
||||
selected: true,
|
||||
}],
|
||||
selected_candidate_id: Some("candidate-1".to_string()),
|
||||
cover_image_src: Some("/ready.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
});
|
||||
|
||||
let failed = mark_failed_puzzle_result_draft_generation(draft);
|
||||
|
||||
assert_eq!(failed.generation_status, "failed");
|
||||
assert_eq!(failed.levels[0].generation_status, "failed");
|
||||
assert_eq!(failed.levels[1].generation_status, "ready");
|
||||
assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_seed_keeps_multiline_picture_description() {
|
||||
let anchor_pack = infer_anchor_pack(
|
||||
|
||||
@@ -68,6 +68,15 @@ pub struct PuzzleDraftCompileInput {
|
||||
pub compiled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleDraftCompileFailureInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleGeneratedImagesSaveInput {
|
||||
|
||||
@@ -52,7 +52,8 @@ pub use mapper::{
|
||||
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
|
||||
@@ -99,7 +99,8 @@ pub use self::puzzle::{
|
||||
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
|
||||
@@ -636,6 +636,14 @@ pub struct PuzzleAgentMessageFinalizeRecordInput {
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleDraftCompileFailureRecordInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleGeneratedImagesSaveRecordInput {
|
||||
pub session_id: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
@@ -468,6 +468,7 @@ pub mod list_visual_novel_runtime_history_procedure;
|
||||
pub mod list_visual_novel_works_procedure;
|
||||
pub mod list_wooden_fish_works_procedure;
|
||||
pub mod mark_profile_recharge_order_paid_and_return_procedure;
|
||||
pub mod mark_puzzle_draft_generation_failed_procedure;
|
||||
pub mod match_3_d_agent_message_finalize_input_type;
|
||||
pub mod match_3_d_agent_message_row_type;
|
||||
pub mod match_3_d_agent_message_snapshot_type;
|
||||
@@ -597,6 +598,7 @@ pub mod puzzle_audio_asset_type;
|
||||
pub mod puzzle_board_snapshot_type;
|
||||
pub mod puzzle_cell_position_type;
|
||||
pub mod puzzle_creator_intent_type;
|
||||
pub mod puzzle_draft_compile_failure_input_type;
|
||||
pub mod puzzle_draft_compile_input_type;
|
||||
pub mod puzzle_draft_level_type;
|
||||
pub mod puzzle_event_kind_type;
|
||||
@@ -1497,6 +1499,7 @@ pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_h
|
||||
pub use list_visual_novel_works_procedure::list_visual_novel_works;
|
||||
pub use list_wooden_fish_works_procedure::list_wooden_fish_works;
|
||||
pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return;
|
||||
pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed;
|
||||
pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput;
|
||||
pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow;
|
||||
pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot;
|
||||
@@ -1626,6 +1629,7 @@ pub use puzzle_audio_asset_type::PuzzleAudioAsset;
|
||||
pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot;
|
||||
pub use puzzle_cell_position_type::PuzzleCellPosition;
|
||||
pub use puzzle_creator_intent_type::PuzzleCreatorIntent;
|
||||
pub use puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput;
|
||||
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
|
||||
pub use puzzle_draft_level_type::PuzzleDraftLevel;
|
||||
pub use puzzle_event_kind_type::PuzzleEventKind;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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};
|
||||
|
||||
use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult;
|
||||
use super::puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct MarkPuzzleDraftGenerationFailedArgs {
|
||||
pub input: PuzzleDraftCompileFailureInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for MarkPuzzleDraftGenerationFailedArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `mark_puzzle_draft_generation_failed`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait mark_puzzle_draft_generation_failed {
|
||||
fn mark_puzzle_draft_generation_failed(&self, input: PuzzleDraftCompileFailureInput) {
|
||||
self.mark_puzzle_draft_generation_failed_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn mark_puzzle_draft_generation_failed_then(
|
||||
&self,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl mark_puzzle_draft_generation_failed for super::RemoteProcedures {
|
||||
fn mark_puzzle_draft_generation_failed_then(
|
||||
&self,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
|
||||
"mark_puzzle_draft_generation_failed",
|
||||
MarkPuzzleDraftGenerationFailedArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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 PuzzleDraftCompileFailureInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleDraftCompileFailureInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -167,6 +167,36 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_puzzle_draft_generation_failed(
|
||||
&self,
|
||||
input: PuzzleDraftCompileFailureRecordInput,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
let procedure_input = PuzzleDraftCompileFailureInput {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
error_message: input.error_message,
|
||||
failed_at_micros: input.failed_at_micros,
|
||||
};
|
||||
|
||||
self.call_after_connect(
|
||||
"mark_puzzle_draft_generation_failed",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.mark_puzzle_draft_generation_failed_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_puzzle_agent_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_puzzle_generated_images(
|
||||
&self,
|
||||
input: PuzzleGeneratedImagesSaveRecordInput,
|
||||
|
||||
@@ -11,20 +11,20 @@ use module_puzzle::{
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
|
||||
PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput,
|
||||
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
|
||||
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
|
||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
||||
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
|
||||
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
|
||||
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
|
||||
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput,
|
||||
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
|
||||
build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
|
||||
normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile,
|
||||
replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level,
|
||||
selected_puzzle_level, tag_similarity_score,
|
||||
mark_failed_puzzle_result_draft_generation, normalize_puzzle_draft, normalize_puzzle_levels,
|
||||
normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles,
|
||||
selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score,
|
||||
};
|
||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||
use module_runtime::visible_runtime_profile_user_tags;
|
||||
@@ -363,6 +363,25 @@ pub fn compile_puzzle_agent_draft(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn mark_puzzle_draft_generation_failed(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
) -> PuzzleAgentSessionProcedureResult {
|
||||
match ctx.try_with_tx(|tx| mark_puzzle_draft_generation_failed_tx(tx, input.clone())) {
|
||||
Ok(session) => PuzzleAgentSessionProcedureResult {
|
||||
ok: true,
|
||||
session: Some(session),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||||
ok: false,
|
||||
session: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存拼图入口表单草稿。
|
||||
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
|
||||
#[spacetimedb::procedure]
|
||||
@@ -999,6 +1018,60 @@ fn compile_puzzle_agent_draft_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_draft_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
|
||||
let draft = match deserialize_optional_draft(&row.draft_json)? {
|
||||
Some(draft) => mark_failed_puzzle_result_draft_generation(draft),
|
||||
None => {
|
||||
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
|
||||
let messages = list_session_messages(ctx, &row.session_id);
|
||||
mark_failed_puzzle_result_draft_generation(compile_result_draft_from_seed(
|
||||
&anchor_pack,
|
||||
&messages,
|
||||
Some(&row.seed_text),
|
||||
))
|
||||
}
|
||||
};
|
||||
upsert_puzzle_draft_work_profile(
|
||||
ctx,
|
||||
&row.session_id,
|
||||
&row.owner_user_id,
|
||||
&draft,
|
||||
input.failed_at_micros,
|
||||
)?;
|
||||
|
||||
replace_puzzle_agent_session(
|
||||
ctx,
|
||||
&row,
|
||||
PuzzleAgentSessionRow {
|
||||
session_id: row.session_id.clone(),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
seed_text: row.seed_text.clone(),
|
||||
current_turn: row.current_turn,
|
||||
progress_percent: row.progress_percent.max(88),
|
||||
stage: row.stage,
|
||||
anchor_pack_json: row.anchor_pack_json.clone(),
|
||||
draft_json: Some(serialize_json(&draft)),
|
||||
last_assistant_reply: Some(input.error_message),
|
||||
published_profile_id: row.published_profile_id.clone(),
|
||||
created_at: row.created_at,
|
||||
updated_at,
|
||||
},
|
||||
);
|
||||
|
||||
get_puzzle_agent_session_tx(
|
||||
ctx,
|
||||
PuzzleAgentSessionGetInput {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn save_puzzle_form_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleFormDraftSaveInput,
|
||||
|
||||
@@ -702,14 +702,22 @@ function mockNarrowMobileLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
function ProfileHomeViewHarness({
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
profileDashboardOverrides = {},
|
||||
userOverrides = {},
|
||||
activeTab = 'profile',
|
||||
profileTaskRefreshKey = 0,
|
||||
}: {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileDashboardOverrides?: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
) {
|
||||
return render(
|
||||
>;
|
||||
userOverrides?: Partial<AuthUser>;
|
||||
activeTab?: RpgEntryHomeViewProps['activeTab'];
|
||||
profileTaskRefreshKey?: number;
|
||||
}) {
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
@@ -742,7 +750,7 @@ function renderProfileView(
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="profile"
|
||||
activeTab={activeTab}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
@@ -772,8 +780,27 @@ function renderProfileView(
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
profileTaskRefreshKey = 0,
|
||||
) {
|
||||
return render(
|
||||
<ProfileHomeViewHarness
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileDashboardOverrides={profileDashboardOverrides}
|
||||
userOverrides={userOverrides}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2026,7 +2053,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
|
||||
).toHaveLength(5);
|
||||
).toHaveLength(4);
|
||||
expect(
|
||||
shortcutRegion
|
||||
.querySelector('.platform-profile-shortcut-grid')
|
||||
@@ -2034,7 +2061,6 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
).toBe(true);
|
||||
for (const label of [
|
||||
'泥点充值',
|
||||
'邀请好友',
|
||||
'兑换码',
|
||||
'玩家社区',
|
||||
'反馈与建议',
|
||||
@@ -2172,28 +2198,25 @@ test('wallet ledger modal shows empty and error states', async () => {
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile invite shortcut shows reward subtitle and invited users', async () => {
|
||||
test('profile community shortcut shows reward subtitle and invited users', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
|
||||
|
||||
await user.click(inviteButton);
|
||||
await user.click(communityButton);
|
||||
|
||||
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await screen.findByText('邀请一个用户注册,双方都可以获得30泥点。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
|
||||
expect(screen.getByText('成功邀请')).toBeTruthy();
|
||||
expect(screen.getByText('被邀请玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('已奖')).toBeNull();
|
||||
expect(screen.queryByText('今日')).toBeNull();
|
||||
expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(screen.getByText('微信群')).toBeTruthy();
|
||||
expect(screen.getByText('QQ群')).toBeTruthy();
|
||||
expect(screen.queryByText('成功邀请')).toBeNull();
|
||||
expect(screen.queryByText('被邀请玩家')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => {
|
||||
@@ -2203,50 +2226,55 @@ test('profile page hides legacy redeem invite secondary shortcut for fresh accou
|
||||
{ createdAt: buildFreshProfileCreatedAt() },
|
||||
);
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(inviteButton).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
expect(communityButton).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
|
||||
mockBuildReferralCenter({
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: true,
|
||||
boundInviterUserId: 'user-2',
|
||||
boundAt: '2026-05-01T08:00:00Z',
|
||||
}),
|
||||
);
|
||||
const { unmount } = renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /邀请好友/u }));
|
||||
await screen.findByText('成功邀请');
|
||||
const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /邀请好友/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
|
||||
).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
unmount();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
|
||||
const expiredShortcutRegion = screen.getByRole('region', {
|
||||
name: '常用功能',
|
||||
});
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /邀请好友/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
test('invite query opens login modal for logged out users', async () => {
|
||||
@@ -2303,6 +2331,22 @@ test('profile redeem invite query modal submits code after login', async () => {
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('profile task center reloads when refresh key changes', async () => {
|
||||
const { rerender } = renderProfileView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ProfileHomeViewHarness profileTaskRefreshKey={1} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('opens reward code modal from profile action on mobile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -2330,8 +2374,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
?.classList.contains('platform-profile-shortcut-grid'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||
).toBeTruthy();
|
||||
within(shortcutRegion).queryByRole('button', { name: /邀请好友/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Star,
|
||||
ThumbsUp,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -50,7 +49,6 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
|
||||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||||
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
|
||||
import profileCoinsImage from '../../../media/profile/_Image (4).png';
|
||||
import profileInviteImage from '../../../media/profile/_Image (5).png';
|
||||
import profileGiftImage from '../../../media/profile/_Image (6).png';
|
||||
import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||
@@ -4026,6 +4024,7 @@ export function RpgEntryHomeView({
|
||||
useState<ProfileTaskCenterResponse | null>(null);
|
||||
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
|
||||
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
|
||||
const taskCenterRequestIdRef = useRef(0);
|
||||
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
|
||||
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
@@ -4045,6 +4044,7 @@ export function RpgEntryHomeView({
|
||||
: readProfileInviteCodeFromLocationSearch(window.location.search),
|
||||
[],
|
||||
);
|
||||
const promptedLoginForInviteQueryRef = useRef(false);
|
||||
const autoOpenedInviteQueryRef = useRef(false);
|
||||
const [referralRedeemCode, setReferralRedeemCode] = useState(
|
||||
pendingProfileInviteCode,
|
||||
@@ -4375,12 +4375,15 @@ export function RpgEntryHomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
autoOpenedInviteQueryRef.current = true;
|
||||
if (!authUi?.user) {
|
||||
if (!promptedLoginForInviteQueryRef.current) {
|
||||
promptedLoginForInviteQueryRef.current = true;
|
||||
authUi?.openLoginModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
autoOpenedInviteQueryRef.current = true;
|
||||
setReferralRedeemCode(pendingProfileInviteCode);
|
||||
setReferralError(null);
|
||||
setReferralSuccess(null);
|
||||
@@ -4779,21 +4782,34 @@ export function RpgEntryHomeView({
|
||||
};
|
||||
}, [handleWechatPayResult]);
|
||||
const loadTaskCenter = useCallback(() => {
|
||||
const requestId = ++taskCenterRequestIdRef.current;
|
||||
setTaskCenterError(null);
|
||||
setIsLoadingTaskCenter(true);
|
||||
void getRpgProfileTasks()
|
||||
.then(setTaskCenter)
|
||||
.then((center) => {
|
||||
if (requestId === taskCenterRequestIdRef.current) {
|
||||
setTaskCenter(center);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (requestId !== taskCenterRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setTaskCenter(null);
|
||||
setTaskCenterError(
|
||||
error instanceof Error ? error.message : '读取每日任务失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingTaskCenter(false));
|
||||
.finally(() => {
|
||||
if (requestId === taskCenterRequestIdRef.current) {
|
||||
setIsLoadingTaskCenter(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'profile' || !isAuthenticated) {
|
||||
taskCenterRequestIdRef.current += 1;
|
||||
setTaskCenter(null);
|
||||
setTaskCenterError(null);
|
||||
return;
|
||||
@@ -6243,13 +6259,6 @@ export function RpgEntryHomeView({
|
||||
imageSrc={profileCoinsImage}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel="双方得 30 泥点"
|
||||
icon={UserPlus}
|
||||
imageSrc={profileInviteImage}
|
||||
onClick={() => openProfilePopupPanel('invite')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="领取福利"
|
||||
|
||||
Reference in New Issue
Block a user