修复拼图生成队列与草稿恢复
补齐拼图队列状态回传,生成页展示排队/运行状态 修复草稿进入时生成中与结果页路由恢复逻辑 避免新增空白关卡立即自动保存并保留编辑弹窗 恢复稳定拼图 profile 缺失时的同源 session 草稿投影 让外部生成 worker/controller 跳过 HTTP 认证快照恢复 补充外部生成 worker 与拼图草稿排障记录
This commit is contained in:
@@ -80,6 +80,14 @@
|
||||
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id` 与 `lease_expires_at` 会更新,完成后 session 进入 ready 或 failed;inline 模式下不应产生新的 `external_generation_job`。
|
||||
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部生成 worker 不应等待 HTTP 认证快照恢复
|
||||
|
||||
- 现象:`genarrative-external-generation-worker@1.service` 在 systemd 中显示 active,但 `external_generation_job` 长时间保持 `pending`;worker 日志每 5 秒出现 `export_auth_store_snapshot_from_tables` 订阅失败,例如缺少 `public_work_gallery_entry` 公开 read model 表。
|
||||
- 原因:独立 worker / controller 是非 HTTP 角色,不承接用户登录态恢复;如果启动路径复用 HTTP `api-server` 的认证快照恢复,SpacetimeDB 认证投影或公开 read model 漂移会把 worker claim 循环挡在启动前。
|
||||
- 处理:`GENARRATIVE_PROCESS_ROLE=external-generation-worker` 和 `external-generation-controller` 启动时只构建空 auth store 的 `AppState`,不调用 SpacetimeDB 认证快照导出;只有 `api` / `all` 这类 HTTP 角色需要在启动时恢复认证快照并在依赖不可用时重试或进入 503 降级。
|
||||
- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证快照恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为 `export_auth_store_snapshot_from_tables` 缺表而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。
|
||||
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
||||
|
||||
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
||||
@@ -1800,6 +1808,10 @@
|
||||
|
||||
2026-05-22 补充:结果页关卡详情的“关卡测试”不能把单关 `draft` 传给父级再调用 `updatePuzzleWork`。`updatePuzzleWork` 会同步 `puzzle_work_profile.levels_json` 和 source session 草稿,单关快照会把整份多关卡草稿覆盖成一个关卡,退出重进后只剩最后测试的关卡且序号表现为第一关。修复口径是 `PuzzleResultView` 始终传完整 `syncedDraft`,额外用 `{ levelId }` 指定起始关卡;父级持久化完整 levels 后调用 `startLocalPuzzleRun(item, levelId)`。
|
||||
|
||||
2026-06-18 补充:结果页点击“新增关卡”只是在本地打开一个空白占位关卡,不应立刻进入自动保存。空白占位如果被写入 `/api/runtime/puzzle/works/{profile_id}`,在作品 profile 投影尚未稳定存在时会触发 `update_puzzle_work` 404,并且后续 session/draft 回读可能把当前详情弹窗关闭。修复口径是自动保存比较和 payload 过滤掉“后端基线中不存在且完全空白”的本地关卡;用户填写名称、描述、参考图或开始生成后再保存。`mergeDraftEditStateWithIncomingState(...)` 还要保留本地空白占位,避免 incoming draft 刷新时移除正在编辑的弹窗。
|
||||
|
||||
2026-06-18 补充:改造流的 `creative_agent` 草稿写回会用 `puzzle-session-*` 派生出的 `puzzle-profile-*` 调用 `update_puzzle_work`;如果前置 `create_puzzle_agent_session` 已写入 `puzzle_agent_session`,但派生的 `puzzle_work_profile` 草稿投影缺失,写回会报“拼图作品不存在”。首图生成或结果页保存也可能踩到同一缺口。修复口径是在 SpacetimeDB `update_puzzle_work_tx` 里只对稳定 `puzzle-profile-*` 反推同源 `puzzle-session-*`,确认 owner 匹配、session 未发布且有 draft 后恢复 draft profile,再继续更新;不要在前端重试或凭空创建任意 profile,也不要恢复已发布 session。
|
||||
|
||||
## 拼图上传图关闭 AI 重绘不要走首图生图
|
||||
|
||||
- 现象:用户在拼图入口页或结果页关卡详情上传图片并关闭 AI 重绘后,生成页仍显示“生成拼图首图”,或者后端仍调用 `generate_puzzle_image_candidates` 生成第一张 1:1 候选图。
|
||||
|
||||
@@ -1431,4 +1431,62 @@ mod tests {
|
||||
assert_eq!(next["workTitle"], json!("轻松家庭拼图"));
|
||||
assert_eq!(next["levels"][0]["pictureDescription"], json!("旧图面"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_edit_writeback_uses_stable_puzzle_profile_from_target_session() {
|
||||
let target_session_id = "puzzle-session-b1cf819ea2d944c4a7e34ca668f8a97c";
|
||||
let profile_id = build_puzzle_result_profile_id(target_session_id);
|
||||
let draft = json!({
|
||||
"workTitle": "雨夜猫街",
|
||||
"workDescription": "在灯牌下寻找猫的拼图",
|
||||
"summary": "雨夜猫街拼图",
|
||||
"themeTags": ["猫", "雨夜"],
|
||||
"coverImageSrc": "/generated-puzzle-assets/cat.png",
|
||||
"coverAssetId": "asset-cat",
|
||||
"levels": [{
|
||||
"levelId": "puzzle-level-1",
|
||||
"levelName": "灯牌下的猫",
|
||||
"pictureDescription": "一只猫在雨夜灯牌下回头",
|
||||
"pictureReference": null,
|
||||
"selectedCandidateId": "candidate-1",
|
||||
"coverImageSrc": "/generated-puzzle-assets/cat.png",
|
||||
"coverAssetId": "asset-cat",
|
||||
"generationStatus": "ready",
|
||||
"candidates": []
|
||||
}]
|
||||
});
|
||||
|
||||
let input = build_puzzle_work_update_from_draft(profile_id, "user-1".to_string(), &draft)
|
||||
.expect("writeback payload should build");
|
||||
|
||||
assert_eq!(
|
||||
input.profile_id,
|
||||
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c"
|
||||
);
|
||||
assert_eq!(input.owner_user_id, "user-1");
|
||||
assert_eq!(input.work_title, "雨夜猫街");
|
||||
assert_eq!(input.work_description, "在灯牌下寻找猫的拼图");
|
||||
assert_eq!(input.level_name, "灯牌下的猫");
|
||||
assert_eq!(input.summary, "雨夜猫街拼图");
|
||||
assert_eq!(input.theme_tags, vec!["猫".to_string(), "雨夜".to_string()]);
|
||||
assert_eq!(
|
||||
input.cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/cat.png")
|
||||
);
|
||||
assert_eq!(input.cover_asset_id.as_deref(), Some("asset-cat"));
|
||||
|
||||
let levels: Vec<Value> = serde_json::from_str(
|
||||
input
|
||||
.levels_json
|
||||
.as_deref()
|
||||
.expect("levels should be serialized"),
|
||||
)
|
||||
.expect("levels json should parse");
|
||||
assert_eq!(levels[0]["level_id"], json!("puzzle-level-1"));
|
||||
assert_eq!(levels[0]["level_name"], json!("灯牌下的猫"));
|
||||
assert_eq!(
|
||||
levels[0]["picture_description"],
|
||||
json!("一只猫在雨夜灯牌下回头")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,6 +744,7 @@ mod tests {
|
||||
started_at: Some("2026-06-03T00:00:00Z".to_string()),
|
||||
completed_at: None,
|
||||
updated_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1_748_908_800_000_000,
|
||||
lease_token: lease_token.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
app::{build_router, build_spacetime_unavailable_router},
|
||||
config::AppConfig,
|
||||
config::{AppConfig, ProcessRole},
|
||||
external_generation_worker::run_external_generation_worker,
|
||||
external_generation_worker_controller::run_external_generation_worker_controller,
|
||||
state::{AppState, AppStateInitError},
|
||||
@@ -181,13 +181,11 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
|
||||
async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
|
||||
let process_role = config.process_role;
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"初始化 external generation worker 状态失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let state = build_non_http_app_state_for_startup(config).map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"初始化 external generation worker 状态失败:{error}"
|
||||
))
|
||||
})?;
|
||||
spawn_app_state_background_workers(&state);
|
||||
info!(
|
||||
process_role = process_role.as_str(),
|
||||
@@ -205,6 +203,22 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_non_http_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
let process_role = config.process_role;
|
||||
debug_assert!(!should_restore_auth_store_for_startup(process_role));
|
||||
info!(
|
||||
process_role = process_role.as_str(),
|
||||
"非 HTTP 进程跳过 SpacetimeDB 认证快照恢复"
|
||||
);
|
||||
AppState::new_with_empty_auth_store(config)
|
||||
}
|
||||
|
||||
fn should_restore_auth_store_for_startup(process_role: ProcessRole) -> bool {
|
||||
process_role.runs_http()
|
||||
}
|
||||
|
||||
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listen_backlog = config.listen_backlog;
|
||||
@@ -520,8 +534,9 @@ fn is_valid_env_key(key: &str) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from,
|
||||
strip_env_value,
|
||||
should_restore_auth_store_for_startup, strip_env_value,
|
||||
};
|
||||
use crate::config::ProcessRole;
|
||||
|
||||
#[test]
|
||||
fn strip_env_value_removes_wrapping_quotes() {
|
||||
@@ -567,4 +582,16 @@ mod tests {
|
||||
fn startup_dependency_retry_interval_is_short_enough_for_service_recovery() {
|
||||
assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_store_startup_restore_is_limited_to_http_roles() {
|
||||
assert!(should_restore_auth_store_for_startup(ProcessRole::Api));
|
||||
assert!(should_restore_auth_store_for_startup(ProcessRole::All));
|
||||
assert!(!should_restore_auth_store_for_startup(
|
||||
ProcessRole::ExternalGenerationWorker
|
||||
));
|
||||
assert!(!should_restore_auth_store_for_startup(
|
||||
ProcessRole::ExternalGenerationController
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::{
|
||||
creation_audio::CreationAudioAsset,
|
||||
external_generation::{ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord},
|
||||
puzzle_agent::{
|
||||
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
||||
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
|
||||
@@ -52,7 +53,7 @@ use shared_contracts::{
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::{
|
||||
ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord,
|
||||
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, PuzzleAgentMessageRecord,
|
||||
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput,
|
||||
@@ -238,6 +239,32 @@ fn mark_puzzle_initial_generation_started_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn map_puzzle_queue_job_status(
|
||||
job: &ExternalGenerationJobRecord,
|
||||
fallback_progress: u32,
|
||||
) -> ExternalGenerationJobStatusRecord {
|
||||
let (status, phase_detail, default_progress) = match job.status.as_str() {
|
||||
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
|
||||
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
|
||||
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
|
||||
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
|
||||
};
|
||||
|
||||
ExternalGenerationJobStatusRecord {
|
||||
operation_id: job.job_id.clone(),
|
||||
status,
|
||||
phase_label: job.request_label.clone(),
|
||||
phase_detail: phase_detail.to_string(),
|
||||
progress: fallback_progress
|
||||
.max(default_progress)
|
||||
.min(100)
|
||||
.try_into()
|
||||
.unwrap_or(100),
|
||||
error: job.last_error_message.clone(),
|
||||
updated_at_micros: job.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ExternalGenerationWriteLeaseGuard {
|
||||
pub(crate) job_id: Option<String>,
|
||||
|
||||
@@ -718,6 +718,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
phase_detail: "首关草稿生成已在后台处理中。".to_string(),
|
||||
progress: session.progress_percent.max(10),
|
||||
error: None,
|
||||
queue_state: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -774,6 +775,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
},
|
||||
progress: 100,
|
||||
error: None,
|
||||
queue_state: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -861,7 +863,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: job.job_id,
|
||||
operation_id: job.job_id.clone(),
|
||||
operation_type: "compile_puzzle_draft".to_string(),
|
||||
status: status.to_string(),
|
||||
phase_label: "首关拼图草稿".to_string(),
|
||||
@@ -871,7 +873,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
"首关草稿编译已进入后台队列。".to_string()
|
||||
},
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
error: job.last_error_message.clone(),
|
||||
queue_state: Some(map_puzzle_queue_job_status(&job, progress)),
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -1005,6 +1008,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
phase_detail: "关卡图片生成已完成。".to_string(),
|
||||
progress: 100,
|
||||
error: None,
|
||||
queue_state: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -1071,13 +1075,14 @@ pub async fn execute_puzzle_agent_action(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: job.job_id,
|
||||
operation_id: job.job_id.clone(),
|
||||
operation_type: "generate_puzzle_images".to_string(),
|
||||
status: status.to_string(),
|
||||
phase_label: "拼图图片生成".to_string(),
|
||||
phase_detail: "关卡图片生成已进入后台队列。".to_string(),
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
error: job.last_error_message.clone(),
|
||||
queue_state: Some(map_puzzle_queue_job_status(&job, progress)),
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -1145,6 +1150,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
phase_detail: "拼图 UI 背景图生成已完成。".to_string(),
|
||||
progress: 100,
|
||||
error: None,
|
||||
queue_state: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -1211,13 +1217,14 @@ pub async fn execute_puzzle_agent_action(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: job.job_id,
|
||||
operation_id: job.job_id.clone(),
|
||||
operation_type: "generate_puzzle_ui_background".to_string(),
|
||||
status: status.to_string(),
|
||||
phase_label: "UI 背景图生成".to_string(),
|
||||
phase_detail: "拼图 UI 背景图生成已进入后台队列。".to_string(),
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
error: job.last_error_message.clone(),
|
||||
queue_state: Some(map_puzzle_queue_job_status(&job, progress)),
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -1390,6 +1397,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
phase_detail: "拼图作品已发布到广场。".to_string(),
|
||||
progress: 100,
|
||||
error: None,
|
||||
queue_state: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
@@ -1416,6 +1424,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
phase_detail: phase_detail.to_string(),
|
||||
progress: 100,
|
||||
error: None,
|
||||
queue_state: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::creation_audio::CreationAudioAsset;
|
||||
use crate::external_generation::ExternalGenerationJobStatusRecord;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -280,6 +281,8 @@ pub struct PuzzleAgentOperationResponse {
|
||||
pub progress: u32,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -1874,12 +1874,7 @@ fn update_puzzle_work_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleWorkUpsertInput,
|
||||
) -> Result<PuzzleWorkProfile, String> {
|
||||
let row = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(&input.profile_id)
|
||||
.ok_or_else(|| "拼图作品不存在".to_string())?;
|
||||
let row = find_or_recover_puzzle_work_profile_row_for_update(ctx, &input)?;
|
||||
if row.owner_user_id != input.owner_user_id {
|
||||
return Err("无权修改该拼图作品".to_string());
|
||||
}
|
||||
@@ -1957,6 +1952,97 @@ fn update_puzzle_work_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn find_or_recover_puzzle_work_profile_row_for_update(
|
||||
ctx: &TxContext,
|
||||
input: &PuzzleWorkUpsertInput,
|
||||
) -> Result<PuzzleWorkProfileRow, String> {
|
||||
if let Some(row) = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(&input.profile_id)
|
||||
{
|
||||
return Ok(row);
|
||||
}
|
||||
|
||||
let recovery = resolve_stable_puzzle_work_profile_recovery_ids(&input.profile_id)
|
||||
.ok_or_else(|| "拼图作品不存在".to_string())?;
|
||||
let row = get_owned_session_row(ctx, &recovery.session_id, &input.owner_user_id).map_err(
|
||||
|error| {
|
||||
if error.contains("无权") {
|
||||
"无权修改该拼图作品".to_string()
|
||||
} else {
|
||||
"拼图作品不存在".to_string()
|
||||
}
|
||||
},
|
||||
)?;
|
||||
if row.stage == PuzzleAgentStage::Published
|
||||
|| row.published_profile_id.as_deref() == Some(input.profile_id.as_str())
|
||||
{
|
||||
return Err("拼图作品不存在".to_string());
|
||||
}
|
||||
let recovered_profile_id = recovery.profile_id.clone();
|
||||
let draft = deserialize_draft_required(&row.draft_json)?;
|
||||
let profile = build_recovered_puzzle_work_profile_from_session(
|
||||
recovery,
|
||||
&input.owner_user_id,
|
||||
&draft,
|
||||
input.updated_at_micros,
|
||||
)?;
|
||||
upsert_puzzle_work_profile(ctx, profile)?;
|
||||
ctx.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(&recovered_profile_id)
|
||||
.ok_or_else(|| "拼图作品不存在".to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct PuzzleWorkProfileRecoveryIds {
|
||||
work_id: String,
|
||||
profile_id: String,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
fn resolve_stable_puzzle_work_profile_recovery_ids(
|
||||
profile_id: &str,
|
||||
) -> Option<PuzzleWorkProfileRecoveryIds> {
|
||||
let normalized_profile_id = profile_id.trim();
|
||||
let stable_suffix = normalized_profile_id.strip_prefix("puzzle-profile-")?;
|
||||
if stable_suffix.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let session_id = format!("puzzle-session-{stable_suffix}");
|
||||
let (work_id, derived_profile_id) = build_puzzle_work_ids_from_session_id(&session_id);
|
||||
if derived_profile_id != normalized_profile_id {
|
||||
return None;
|
||||
}
|
||||
Some(PuzzleWorkProfileRecoveryIds {
|
||||
work_id,
|
||||
profile_id: derived_profile_id,
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_recovered_puzzle_work_profile_from_session(
|
||||
recovery: PuzzleWorkProfileRecoveryIds,
|
||||
owner_user_id: &str,
|
||||
draft: &PuzzleResultDraft,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<PuzzleWorkProfile, String> {
|
||||
// 中文注释:update 可能晚于草稿投影落库执行;只恢复稳定 session 派生的草稿 profile。
|
||||
create_work_profile(
|
||||
recovery.work_id,
|
||||
recovery.profile_id,
|
||||
owner_user_id.to_string(),
|
||||
Some(recovery.session_id),
|
||||
"陶泥儿主".to_string(),
|
||||
draft,
|
||||
updated_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn sync_puzzle_source_session_draft_from_work(
|
||||
ctx: &TxContext,
|
||||
work_row: &PuzzleWorkProfileRow,
|
||||
@@ -4243,6 +4329,62 @@ mod tests {
|
||||
assert_eq!(draft.work_title, "我的猫街合集");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_puzzle_profile_id_recovers_source_session_ids() {
|
||||
let ids = resolve_stable_puzzle_work_profile_recovery_ids(
|
||||
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c",
|
||||
)
|
||||
.expect("stable puzzle profile should be recoverable");
|
||||
|
||||
assert_eq!(
|
||||
ids.session_id,
|
||||
"puzzle-session-b1cf819ea2d944c4a7e34ca668f8a97c"
|
||||
);
|
||||
assert_eq!(ids.work_id, "puzzle-work-b1cf819ea2d944c4a7e34ca668f8a97c");
|
||||
assert_eq!(
|
||||
ids.profile_id,
|
||||
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c"
|
||||
);
|
||||
assert!(resolve_stable_puzzle_work_profile_recovery_ids("").is_none());
|
||||
assert!(resolve_stable_puzzle_work_profile_recovery_ids("onboarding-profile-1").is_none());
|
||||
assert!(resolve_stable_puzzle_work_profile_recovery_ids("puzzle-profile-").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_puzzle_work_profile_keeps_draft_identity() {
|
||||
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
|
||||
let draft = compile_result_draft_from_seed(
|
||||
&anchor_pack,
|
||||
&[],
|
||||
Some("画面描述:一只猫在雨夜灯牌下回头。"),
|
||||
);
|
||||
let recovery = resolve_stable_puzzle_work_profile_recovery_ids(
|
||||
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c",
|
||||
)
|
||||
.expect("stable ids should resolve");
|
||||
|
||||
let profile =
|
||||
build_recovered_puzzle_work_profile_from_session(recovery, "user-1", &draft, 1_000)
|
||||
.expect("profile should recover from draft");
|
||||
|
||||
assert_eq!(
|
||||
profile.work_id,
|
||||
"puzzle-work-b1cf819ea2d944c4a7e34ca668f8a97c"
|
||||
);
|
||||
assert_eq!(
|
||||
profile.profile_id,
|
||||
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c"
|
||||
);
|
||||
assert_eq!(
|
||||
profile.source_session_id.as_deref(),
|
||||
Some("puzzle-session-b1cf819ea2d944c4a7e34ca668f8a97c")
|
||||
);
|
||||
assert_eq!(profile.owner_user_id, "user-1");
|
||||
assert_eq!(profile.publication_status, PuzzlePublicationStatus::Draft);
|
||||
assert_eq!(profile.updated_at_micros, 1_000);
|
||||
assert_eq!(profile.levels.len(), draft.levels.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_generation_returns_result_draft_stage_instead_of_refining() {
|
||||
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
|
||||
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
fetchCreationEntryConfig,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
type CreationUrlState,
|
||||
clearCreationUrlState,
|
||||
readCreationUrlState,
|
||||
writeCreationUrlState,
|
||||
@@ -499,6 +500,7 @@ import {
|
||||
type PendingDraftShelfKind,
|
||||
type PendingDraftShelfMap,
|
||||
type PendingDraftShelfMetadata,
|
||||
buildPuzzleDraftOpenNoticeKeys,
|
||||
resolveBigFishDraftOpenIntent,
|
||||
resolveJumpHopDraftOpenIntent,
|
||||
resolveMatch3DDraftOpenIntent,
|
||||
@@ -909,6 +911,29 @@ function pushPuzzleResultHistoryEntry(
|
||||
writeCreationUrlState(buildPuzzleCreationUrlState(session));
|
||||
}
|
||||
|
||||
function pushPuzzleGeneratingHistoryEntry(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
fallbackState?: CreationUrlState,
|
||||
) {
|
||||
pushAppHistoryPath('/creation/puzzle/generating');
|
||||
writeCreationUrlState(
|
||||
session ? buildPuzzleCreationUrlState(session) : (fallbackState ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildPuzzleCreationUrlStateFromWork(
|
||||
item: PuzzleWorkSummary,
|
||||
): CreationUrlState {
|
||||
const sourceSessionId = item.sourceSessionId?.trim() || null;
|
||||
|
||||
return {
|
||||
sessionId: sourceSessionId,
|
||||
profileId:
|
||||
item.profileId?.trim() || buildPuzzleResultProfileId(sourceSessionId),
|
||||
workId: item.workId?.trim() || buildPuzzleResultWorkId(sourceSessionId),
|
||||
};
|
||||
}
|
||||
|
||||
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS = 3000;
|
||||
const PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS = 160;
|
||||
@@ -4247,6 +4272,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current =
|
||||
response.session.sessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(response.session);
|
||||
setSelectionStage('puzzle-generating');
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
@@ -4391,6 +4417,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = session.sessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(session);
|
||||
setSelectionStage('puzzle-generating');
|
||||
const nextGenerationState = createPuzzleDraftGenerationStateFromPayload(
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(session),
|
||||
@@ -5657,6 +5684,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = nextSession.sessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(nextSession);
|
||||
setSelectionStage('puzzle-generating');
|
||||
|
||||
try {
|
||||
@@ -12060,6 +12088,66 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setSelectedPuzzleDetail(null);
|
||||
const restorePuzzleGeneratingSession = (
|
||||
latestSession: PuzzleAgentSessionSnapshot,
|
||||
) => {
|
||||
const payload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
|
||||
latestSession.updatedAt,
|
||||
);
|
||||
const baseGenerationState =
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'puzzle',
|
||||
{
|
||||
puzzleAiRedraw: payload.aiRedraw ?? true,
|
||||
puzzleProgressPercent:
|
||||
latestSession.draft && !latestSession.draft.formDraft
|
||||
? latestSession.progressPercent
|
||||
: undefined,
|
||||
},
|
||||
startedAtMs,
|
||||
);
|
||||
const generationState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
baseGenerationState,
|
||||
latestSession,
|
||||
);
|
||||
puzzleFlow.setSession(latestSession);
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
setPuzzleGenerationState(generationState);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[latestSession.sessionId]: {
|
||||
session: latestSession,
|
||||
payload,
|
||||
generationState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = latestSession.sessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(latestSession);
|
||||
setSelectionStage('puzzle-generating');
|
||||
};
|
||||
const openPuzzleReadyResultSession = (
|
||||
latestSession: PuzzleAgentSessionSnapshot,
|
||||
) => {
|
||||
const payload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
puzzleFlow.setSession(latestSession);
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
setPuzzleGenerationState(null);
|
||||
setPuzzleBackgroundCompileTasks((current) => {
|
||||
const next = { ...current };
|
||||
delete next[latestSession.sessionId];
|
||||
return next;
|
||||
});
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-result';
|
||||
activePuzzleGenerationSessionIdRef.current = null;
|
||||
markDraftNoticeSeen(buildPuzzleDraftOpenNoticeKeys(item));
|
||||
pushPuzzleResultHistoryEntry(latestSession);
|
||||
setSelectionStage('puzzle-result');
|
||||
};
|
||||
|
||||
if (openIntent.type === 'open-published-detail') {
|
||||
await openPuzzleDetail(item.profileId, { tab: 'create' });
|
||||
@@ -12125,6 +12213,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(
|
||||
failedSession,
|
||||
buildPuzzleCreationUrlStateFromWork(item),
|
||||
);
|
||||
setPuzzleGenerationState(failedGenerationState);
|
||||
setSelectionStage('puzzle-generating');
|
||||
return;
|
||||
@@ -12139,6 +12231,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(
|
||||
puzzleSession ?? backgroundTask?.session ?? null,
|
||||
buildPuzzleCreationUrlStateFromWork(item),
|
||||
);
|
||||
setPuzzleGenerationState(rebasedGenerationState);
|
||||
if (backgroundTask) {
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
@@ -12169,6 +12265,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
|
||||
pushPuzzleGeneratingHistoryEntry(rebasedTask.session);
|
||||
setSelectionStage('puzzle-generating');
|
||||
return;
|
||||
}
|
||||
@@ -12177,42 +12274,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
const payload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
|
||||
latestSession.updatedAt,
|
||||
);
|
||||
const baseGenerationState =
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'puzzle',
|
||||
{
|
||||
puzzleAiRedraw: payload.aiRedraw ?? true,
|
||||
puzzleProgressPercent:
|
||||
latestSession.draft && !latestSession.draft.formDraft
|
||||
? latestSession.progressPercent
|
||||
: undefined,
|
||||
},
|
||||
startedAtMs,
|
||||
);
|
||||
const generationState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
baseGenerationState,
|
||||
latestSession,
|
||||
);
|
||||
puzzleFlow.setSession(latestSession);
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
setPuzzleGenerationState(generationState);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[latestSession.sessionId]: {
|
||||
session: latestSession,
|
||||
payload,
|
||||
generationState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
|
||||
setSelectionStage('puzzle-generating');
|
||||
if (
|
||||
!isPuzzleFormOnlyDraft(latestSession) &&
|
||||
isPuzzleCompileActionReady(latestSession)
|
||||
) {
|
||||
openPuzzleReadyResultSession(latestSession);
|
||||
return;
|
||||
}
|
||||
restorePuzzleGeneratingSession(latestSession);
|
||||
return;
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
@@ -12238,6 +12307,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
} else {
|
||||
setPuzzleFormDraftPayload(null);
|
||||
pushPuzzleResultHistoryEntry(restoredSession);
|
||||
selectionStageRef.current = 'puzzle-result';
|
||||
activePuzzleGenerationSessionIdRef.current = null;
|
||||
setSelectionStage('puzzle-result');
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -99,6 +99,58 @@ describe('platformDraftGenerationShelfModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent ignores stale generating notice after puzzle draft is ready', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
generationStatus: 'ready',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'level-1',
|
||||
levelName: '金色数字舞台',
|
||||
pictureDescription: '123456',
|
||||
pictureReference: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
}),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'generating',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent restores generating page from notice even before persisted status catches up', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
generationStatus: 'idle',
|
||||
}),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'generating',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
|
||||
const item = buildMatch3DWork({
|
||||
publicationStatus: 'published',
|
||||
|
||||
@@ -595,7 +595,7 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('adds and deletes levels from the list', async () => {
|
||||
test('keeps a blank new level local until the creator edits it', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
@@ -621,25 +621,36 @@ describe('PuzzleResultView', () => {
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
|
||||
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
expect(screen.queryByText('保存中')).toBeNull();
|
||||
expect(puzzleWorksService.updatePuzzleWork).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
|
||||
target: { value: '钟楼猫街' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
levels: expect.arrayContaining([
|
||||
expect.objectContaining({ levelId: 'puzzle-level-1' }),
|
||||
expect.objectContaining({ levelName: '' }),
|
||||
expect.objectContaining({ levelName: '钟楼猫街' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
|
||||
expect(screen.queryByText('第2关')).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
|
||||
fireEvent.click(screen.getByLabelText('删除关卡 钟楼猫街'));
|
||||
expect(screen.queryByText('钟楼猫街')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -657,6 +668,48 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the blank level dialog open across incoming draft refreshes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const base = createSession();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={base}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={{
|
||||
...base,
|
||||
updatedAt: '2026-04-26T10:00:01.000Z',
|
||||
}}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(
|
||||
(within(dialog).getByLabelText('关卡名称') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(screen.getByLabelText('删除关卡 第2关')).toBeTruthy();
|
||||
expect(screen.queryByText('保存中')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('generates image for a newly added level with the current levels snapshot', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -166,6 +166,96 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOptionalPuzzleLevelText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function normalizePuzzleLevelForSave(level: PuzzleDraftLevel) {
|
||||
return {
|
||||
...level,
|
||||
levelName: level.levelName.trim(),
|
||||
pictureDescription: level.pictureDescription.trim(),
|
||||
pictureReference: normalizeOptionalPuzzleLevelText(level.pictureReference),
|
||||
uiBackgroundPrompt: normalizeOptionalPuzzleLevelText(
|
||||
level.uiBackgroundPrompt,
|
||||
),
|
||||
uiBackgroundImageSrc: normalizeOptionalPuzzleLevelText(
|
||||
level.uiBackgroundImageSrc,
|
||||
),
|
||||
uiBackgroundImageObjectKey: normalizeOptionalPuzzleLevelText(
|
||||
level.uiBackgroundImageObjectKey,
|
||||
),
|
||||
levelSceneImageSrc: normalizeOptionalPuzzleLevelText(
|
||||
level.levelSceneImageSrc,
|
||||
),
|
||||
levelSceneImageObjectKey: normalizeOptionalPuzzleLevelText(
|
||||
level.levelSceneImageObjectKey,
|
||||
),
|
||||
uiSpritesheetImageSrc: normalizeOptionalPuzzleLevelText(
|
||||
level.uiSpritesheetImageSrc,
|
||||
),
|
||||
uiSpritesheetImageObjectKey: normalizeOptionalPuzzleLevelText(
|
||||
level.uiSpritesheetImageObjectKey,
|
||||
),
|
||||
levelBackgroundImageSrc: normalizeOptionalPuzzleLevelText(
|
||||
level.levelBackgroundImageSrc,
|
||||
),
|
||||
levelBackgroundImageObjectKey: normalizeOptionalPuzzleLevelText(
|
||||
level.levelBackgroundImageObjectKey,
|
||||
),
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDraftEditStateForSave(state: DraftEditState): DraftEditState {
|
||||
return {
|
||||
...state,
|
||||
workTitle: state.workTitle.trim(),
|
||||
workDescription: state.workDescription.trim(),
|
||||
themeTags: normalizeThemeTagInput(state.themeTags.join(',')),
|
||||
levels: state.levels.map(normalizePuzzleLevelForSave),
|
||||
};
|
||||
}
|
||||
|
||||
function isBlankPuzzleLevel(level: PuzzleDraftLevel) {
|
||||
return (
|
||||
!level.levelName.trim() &&
|
||||
!level.pictureDescription.trim() &&
|
||||
!level.pictureReference?.trim() &&
|
||||
!level.uiBackgroundPrompt?.trim() &&
|
||||
!level.uiBackgroundImageSrc?.trim() &&
|
||||
!level.uiBackgroundImageObjectKey?.trim() &&
|
||||
!level.levelSceneImageSrc?.trim() &&
|
||||
!level.levelSceneImageObjectKey?.trim() &&
|
||||
!level.uiSpritesheetImageSrc?.trim() &&
|
||||
!level.uiSpritesheetImageObjectKey?.trim() &&
|
||||
!level.levelBackgroundImageSrc?.trim() &&
|
||||
!level.levelBackgroundImageObjectKey?.trim() &&
|
||||
!level.selectedCandidateId?.trim() &&
|
||||
!level.coverImageSrc?.trim() &&
|
||||
!level.coverAssetId?.trim() &&
|
||||
!level.backgroundMusic &&
|
||||
level.candidates.length === 0 &&
|
||||
(level.generationStatus || 'idle') === 'idle'
|
||||
);
|
||||
}
|
||||
|
||||
function removeUnsavedBlankPuzzleLevels(
|
||||
state: DraftEditState,
|
||||
originalState: DraftEditState,
|
||||
) {
|
||||
const originalLevelIds = new Set(
|
||||
originalState.levels.map((level) => level.levelId),
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
levels: state.levels.filter(
|
||||
(level) =>
|
||||
originalLevelIds.has(level.levelId) || !isBlankPuzzleLevel(level),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackLevelFromDraft(
|
||||
draft: PuzzleResultDraft,
|
||||
): PuzzleDraftLevel {
|
||||
@@ -261,13 +351,20 @@ function mergeDraftEditStateWithIncomingState(
|
||||
incomingLevel?.generationStatus !== 'generating'
|
||||
);
|
||||
});
|
||||
const hasLocalBlankLevel = currentState.levels.some(
|
||||
(level) =>
|
||||
!incomingLevelsById.has(level.levelId) && isBlankPuzzleLevel(level),
|
||||
);
|
||||
|
||||
if (!shouldPreserveLocalEdits) {
|
||||
if (!shouldPreserveLocalEdits && !hasLocalBlankLevel) {
|
||||
return incomingState;
|
||||
}
|
||||
|
||||
const mergedLevels = currentState.levels.map((level) => {
|
||||
const incomingLevel = incomingLevelsById.get(level.levelId);
|
||||
if (!incomingLevel && isBlankPuzzleLevel(level)) {
|
||||
return level;
|
||||
}
|
||||
if (
|
||||
!incomingLevel ||
|
||||
level.generationStatus !== 'generating' ||
|
||||
@@ -1411,36 +1508,17 @@ export function PuzzleResultView({
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedState: DraftEditState = {
|
||||
...editState,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
themeTags: normalizeThemeTagInput(editState.themeTags.join(',')),
|
||||
levels: editState.levels.map((level) => ({
|
||||
...level,
|
||||
levelName: level.levelName.trim(),
|
||||
pictureDescription: level.pictureDescription.trim(),
|
||||
pictureReference: level.pictureReference?.trim() || null,
|
||||
uiBackgroundPrompt: level.uiBackgroundPrompt?.trim() || null,
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
|
||||
uiBackgroundImageObjectKey:
|
||||
level.uiBackgroundImageObjectKey?.trim() || null,
|
||||
levelSceneImageSrc: level.levelSceneImageSrc?.trim() || null,
|
||||
levelSceneImageObjectKey:
|
||||
level.levelSceneImageObjectKey?.trim() || null,
|
||||
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
|
||||
uiSpritesheetImageObjectKey:
|
||||
level.uiSpritesheetImageObjectKey?.trim() || null,
|
||||
levelBackgroundImageSrc: level.levelBackgroundImageSrc?.trim() || null,
|
||||
levelBackgroundImageObjectKey:
|
||||
level.levelBackgroundImageObjectKey?.trim() || null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
})),
|
||||
};
|
||||
const originalState =
|
||||
savedEditStateRef.current ?? createDraftEditState(draft);
|
||||
const normalizedOriginalState =
|
||||
normalizeDraftEditStateForSave(originalState);
|
||||
const normalizedState = removeUnsavedBlankPuzzleLevels(
|
||||
normalizeDraftEditStateForSave(editState),
|
||||
normalizedOriginalState,
|
||||
);
|
||||
const changed =
|
||||
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
|
||||
JSON.stringify(normalizedState) !==
|
||||
JSON.stringify(normalizedOriginalState);
|
||||
|
||||
if (!changed || normalizedState.levels.length <= 0) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user