1
This commit is contained in:
@@ -23,8 +23,8 @@ use shared_contracts::assets::{
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||||
@@ -119,6 +119,7 @@ pub async fn get_asset_read_url(
|
||||
pub async fn get_asset_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Query(query): Query<AssetHistoryQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset_kind = query.kind.trim().to_string();
|
||||
@@ -133,18 +134,23 @@ pub async fn get_asset_history(
|
||||
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_asset_history(module_assets::AssetHistoryListInput {
|
||||
asset_kind,
|
||||
limit: query.limit.unwrap_or(120).clamp(1, 120),
|
||||
})
|
||||
.list_asset_history(build_asset_history_list_input(asset_kind, query.limit))
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_error)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AssetHistoryListResponse {
|
||||
assets: entries
|
||||
.into_iter()
|
||||
// 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。
|
||||
.filter(|entry| {
|
||||
is_asset_history_owned_by(
|
||||
entry.owner_user_id.as_deref(),
|
||||
owner_user_id.as_str(),
|
||||
)
|
||||
})
|
||||
.map(|entry| AssetHistoryEntryPayload {
|
||||
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
|
||||
asset_object_id: entry.asset_object_id,
|
||||
@@ -296,6 +302,25 @@ fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
|
||||
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
|
||||
}
|
||||
|
||||
fn is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool {
|
||||
let owner_user_id = owner_user_id.trim();
|
||||
!owner_user_id.is_empty()
|
||||
&& entry_owner_user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
== Some(owner_user_id)
|
||||
}
|
||||
|
||||
fn build_asset_history_list_input(
|
||||
asset_kind: String,
|
||||
limit: Option<u32>,
|
||||
) -> module_assets::AssetHistoryListInput {
|
||||
module_assets::AssetHistoryListInput {
|
||||
asset_kind,
|
||||
limit: limit.unwrap_or(120).clamp(1, 120),
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_asset_history_kind_message() -> String {
|
||||
format!(
|
||||
"历史素材类型只支持 {}",
|
||||
@@ -490,6 +515,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_history_owner_filter_keeps_only_authenticated_owner_assets() {
|
||||
assert!(super::is_asset_history_owned_by(
|
||||
Some("user-current"),
|
||||
"user-current"
|
||||
));
|
||||
assert!(!super::is_asset_history_owned_by(
|
||||
Some("user-other"),
|
||||
"user-current"
|
||||
));
|
||||
assert!(!super::is_asset_history_owned_by(None, "user-current"));
|
||||
assert!(!super::is_asset_history_owned_by(Some("user-current"), ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_history_input_clamps_limit_for_spacetime_query() {
|
||||
let input =
|
||||
super::build_asset_history_list_input("puzzle_cover_image".to_string(), Some(240));
|
||||
|
||||
assert_eq!(input.asset_kind, "puzzle_cover_image");
|
||||
assert_eq!(input.limit, 120);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
86
server-rs/crates/api-server/src/prompt/puzzle/draft.rs
Normal file
86
server-rs/crates/api-server/src/prompt/puzzle/draft.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
/// 拼图作品草稿生成动作的提示词主源。
|
||||
///
|
||||
/// 拼图结果页草稿本体仍由 SpacetimeDB reducer 按表单/锚点确定性编译;
|
||||
/// 这里收口 api-server 在生成草稿前后需要写入 reducer 的表单 seed 文本,
|
||||
/// 以及草稿首图生成时的 prompt 来源选择,避免业务路由直接拼提示词文本。
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct PuzzleFormSeedPromptParts<'a> {
|
||||
pub(crate) title: Option<&'a str>,
|
||||
pub(crate) work_description: Option<&'a str>,
|
||||
pub(crate) picture_description: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// 将填表式拼图输入编译成 SpacetimeDB 可恢复的表单 seed prompt。
|
||||
pub(crate) fn build_puzzle_form_seed_prompt(parts: PuzzleFormSeedPromptParts<'_>) -> String {
|
||||
[
|
||||
("作品名称", normalize_prompt_part(parts.title)),
|
||||
("作品描述", normalize_prompt_part(parts.work_description)),
|
||||
("画面描述", normalize_prompt_part(parts.picture_description)),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, value)| value.map(|value| format!("{label}:{value}")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 生成作品草稿时,首图 prompt 优先使用玩家当前表单里的画面描述。
|
||||
pub(crate) fn resolve_puzzle_draft_cover_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
draft_summary: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.or_else(|| normalize_prompt_part(Some(draft_summary)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 结果页单关重新生成时,优先使用面板当前编辑态 prompt,再回退关卡画面描述。
|
||||
pub(crate) fn resolve_puzzle_level_image_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_prompt_part(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
||||
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: Some(" 暖灯猫街 "),
|
||||
work_description: Some("雨夜礼物拼图"),
|
||||
picture_description: Some("猫咪在灯牌下回头"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_cover_prompt_prefers_current_picture_description() {
|
||||
let prompt =
|
||||
resolve_puzzle_draft_cover_prompt(Some(" 当前表单画面 "), "旧关卡画面", "作品简介");
|
||||
|
||||
assert_eq!(prompt, "当前表单画面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_image_prompt_falls_back_to_level_description() {
|
||||
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述");
|
||||
|
||||
assert_eq!(prompt, "关卡画面描述");
|
||||
}
|
||||
}
|
||||
@@ -38,17 +38,15 @@ pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> Strin
|
||||
image_prompt
|
||||
}
|
||||
|
||||
fn build_puzzle_image_prompt_text(level_name: &str, prompt: &str) -> String {
|
||||
fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1 正方形拼图关卡,适配 3x3 或 4x4 拼图切块,",
|
||||
"画面要求:1:1",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
level_name = level_name,
|
||||
prompt = prompt,
|
||||
)
|
||||
}
|
||||
@@ -78,10 +76,9 @@ mod tests {
|
||||
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
@@ -93,8 +90,8 @@ mod tests {
|
||||
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
|
||||
|
||||
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod draft;
|
||||
pub(crate) mod image;
|
||||
|
||||
@@ -240,7 +240,10 @@ JSON 结构:
|
||||
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
|
||||
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。
|
||||
- 非敌对聊天 shouldEndChat 必须为 false。
|
||||
- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
|
||||
- 敌对聊天可以随时 shouldEndChat=true。
|
||||
- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。
|
||||
- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。
|
||||
- shouldEndChat=true 时 terminationReason 使用 hostile_breakoff,suggestions 与 functionSuggestions 可以为空。"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NpcChatTurnPromptInput<'a> {
|
||||
@@ -394,6 +397,19 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat && chatted_count >= 4.0 {
|
||||
Some(format!(
|
||||
"敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。",
|
||||
format_prompt_number(chatted_count)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
|
||||
} else {
|
||||
@@ -474,6 +490,9 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
let chatted_count = as_record(payload.npc_state)
|
||||
.and_then(|record| read_number(record.get("chattedCount")))
|
||||
.unwrap_or(0.0);
|
||||
let function_options_block = chat_directive
|
||||
.and_then(|record| record.get("functionOptions"))
|
||||
.map(describe_function_options)
|
||||
@@ -498,6 +517,14 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
} else {
|
||||
Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string())
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some(format!(
|
||||
"敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true,并使用 terminationReason=hostile_breakoff。",
|
||||
format_prompt_number(chatted_count)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string())
|
||||
} else {
|
||||
@@ -526,6 +553,20 @@ pub(crate) fn build_deterministic_npc_reply(
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
pub(crate) fn build_deterministic_hostile_breakoff_reply(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
) -> String {
|
||||
// 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。
|
||||
let player_signal = player_message.trim();
|
||||
if player_signal.is_empty() {
|
||||
return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”");
|
||||
}
|
||||
format!(
|
||||
"{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_fallback(
|
||||
target_character: &Value,
|
||||
player_message: &str,
|
||||
@@ -1066,3 +1107,55 @@ fn format_prompt_number(value: f64) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> {
|
||||
NpcChatTurnPromptInput {
|
||||
world_type: "CUSTOM",
|
||||
character: Box::leak(Box::new(Value::Null)),
|
||||
encounter: Box::leak(Box::new(Value::Null)),
|
||||
monsters: &[],
|
||||
history: &[],
|
||||
context: Box::leak(Box::new(Value::Null)),
|
||||
conversation_history: &[],
|
||||
dialogue: &[],
|
||||
combat_context: None,
|
||||
player_message: "少废话,让开。",
|
||||
npc_state: Box::leak(Box::new(npc_state)),
|
||||
npc_initiates_conversation: false,
|
||||
chat_directive: Some(Box::leak(Box::new(json!({
|
||||
"terminationMode": "hostile_model",
|
||||
"isHostileChat": true,
|
||||
})))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_reply_prompt_mentions_final_threat_after_four_turns() {
|
||||
let input = hostile_prompt_input(json!({
|
||||
"affinity": -12,
|
||||
"chattedCount": 4,
|
||||
}));
|
||||
let prompt = build_npc_chat_turn_reply_prompt(&input);
|
||||
|
||||
assert!(prompt.contains("已聊天轮次:4"));
|
||||
assert!(prompt.contains("战斗前狠话"));
|
||||
assert!(prompt.contains("本轮结束后会超过 4 轮"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_suggestion_prompt_mentions_should_end_chat_signals() {
|
||||
let input = hostile_prompt_input(json!({
|
||||
"affinity": -12,
|
||||
"chattedCount": 4,
|
||||
}));
|
||||
let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。");
|
||||
|
||||
assert!(prompt.contains("shouldEndChat=true"));
|
||||
assert!(prompt.contains("terminationReason=hostile_breakoff"));
|
||||
assert!(prompt.contains("已聊天轮次为 4"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ use shared_contracts::{
|
||||
puzzle_runtime::{
|
||||
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
|
||||
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -56,12 +57,12 @@ use spacetime_client::{
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
|
||||
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
|
||||
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
@@ -72,7 +73,13 @@ use crate::{
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::puzzle::image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
prompt::puzzle::{
|
||||
draft::{
|
||||
PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt,
|
||||
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
||||
},
|
||||
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
},
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_puzzle_agent_turn,
|
||||
@@ -472,7 +479,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| payload.prompt_text.as_deref());
|
||||
if let Err(response) = save_puzzle_form_payload_before_compile(
|
||||
let compile_session_id = match save_puzzle_form_payload_before_compile(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
@@ -482,8 +489,9 @@ pub async fn execute_puzzle_agent_action(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(response);
|
||||
}
|
||||
Ok(next_session_id) => next_session_id,
|
||||
Err(response) => return Err(response),
|
||||
};
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
@@ -492,7 +500,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
@@ -522,7 +530,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
.as_deref()
|
||||
.or(payload.prompt_text.as_deref()),
|
||||
);
|
||||
let session = state
|
||||
let save_result = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
@@ -530,14 +538,36 @@ pub async fn execute_puzzle_agent_action(
|
||||
seed_text,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
});
|
||||
.await;
|
||||
let session = match save_result {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
|
||||
// 中文注释:Maincloud 旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"拼图表单自动保存 procedure 缺失,降级返回当前会话"
|
||||
);
|
||||
state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|fallback_error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(fallback_error),
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(error) => Err(puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)),
|
||||
};
|
||||
(
|
||||
"save_puzzle_form_draft",
|
||||
"表单草稿保存",
|
||||
@@ -547,30 +577,42 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
"generate_puzzle_images" => {
|
||||
let target_level_id = payload.level_id.clone();
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
async {
|
||||
let levels_json = levels_json?;
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let target_level =
|
||||
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let prompt = payload
|
||||
.prompt_text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| target_level.picture_description.clone());
|
||||
let prompt = resolve_puzzle_level_image_prompt(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
@@ -609,6 +651,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id),
|
||||
levels_json,
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
@@ -977,7 +1020,7 @@ pub async fn get_puzzle_gallery_detail(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleGalleryDetailResponse {
|
||||
item: map_puzzle_work_summary_response(&state, item),
|
||||
item: map_puzzle_work_profile_response(&state, item),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1014,7 +1057,7 @@ pub async fn record_puzzle_gallery_like(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleGalleryDetailResponse {
|
||||
item: map_puzzle_work_summary_response(&state, item),
|
||||
item: map_puzzle_work_profile_response(&state, item),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1303,6 +1346,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
"hint" => "puzzle_prop_hint",
|
||||
"reference" => "puzzle_prop_preview",
|
||||
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
|
||||
"extendTime" | "extend_time" => "puzzle_prop_extend_time",
|
||||
_ => {
|
||||
return Err(puzzle_bad_request(
|
||||
&request_context,
|
||||
@@ -1646,6 +1690,7 @@ fn map_puzzle_work_summary_response(
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
publish_ready: item.publish_ready,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,14 +1698,16 @@ fn map_puzzle_work_profile_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkProfileResponse {
|
||||
let mut summary = map_puzzle_work_summary_response(state, item.clone());
|
||||
summary.levels = item
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect();
|
||||
|
||||
PuzzleWorkProfileResponse {
|
||||
summary: map_puzzle_work_summary_response(state, item.clone()),
|
||||
summary,
|
||||
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
|
||||
levels: item
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1675,6 +1722,14 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
next_level_mode: run.next_level_mode,
|
||||
next_level_profile_id: run.next_level_profile_id,
|
||||
next_level_id: run.next_level_id,
|
||||
recommended_next_works: run
|
||||
.recommended_next_works
|
||||
.into_iter()
|
||||
.map(map_puzzle_recommended_next_work_response)
|
||||
.collect(),
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
@@ -1683,6 +1738,19 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_recommended_next_work_response(
|
||||
item: PuzzleRecommendedNextWorkRecord,
|
||||
) -> PuzzleRecommendedNextWorkResponse {
|
||||
PuzzleRecommendedNextWorkResponse {
|
||||
profile_id: item.profile_id,
|
||||
level_name: item.level_name,
|
||||
author_display_name: item.author_display_name,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
similarity_score: item.similarity_score,
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_puzzle_run_author_name(
|
||||
state: &AppState,
|
||||
mut run: PuzzleRunRecord,
|
||||
@@ -1717,6 +1785,14 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_level_request_record),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
next_level_mode: run.next_level_mode,
|
||||
next_level_profile_id: run.next_level_profile_id,
|
||||
next_level_id: run.next_level_id,
|
||||
recommended_next_works: run
|
||||
.recommended_next_works
|
||||
.into_iter()
|
||||
.map(map_puzzle_recommended_next_work_request_record)
|
||||
.collect(),
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
@@ -1725,12 +1801,26 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_recommended_next_work_request_record(
|
||||
item: PuzzleRecommendedNextWorkResponse,
|
||||
) -> PuzzleRecommendedNextWorkRecord {
|
||||
PuzzleRecommendedNextWorkRecord {
|
||||
profile_id: item.profile_id,
|
||||
level_name: item.level_name,
|
||||
author_display_name: item.author_display_name,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
similarity_score: item.similarity_score,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_level_request_record(
|
||||
level: PuzzleRuntimeLevelSnapshotResponse,
|
||||
) -> PuzzleRuntimeLevelRecord {
|
||||
PuzzleRuntimeLevelRecord {
|
||||
run_id: level.run_id,
|
||||
level_index: level.level_index,
|
||||
level_id: level.level_id,
|
||||
grid_size: level.grid_size,
|
||||
profile_id: level.profile_id,
|
||||
level_name: level.level_name,
|
||||
@@ -1823,6 +1913,7 @@ fn map_puzzle_runtime_level_response(
|
||||
PuzzleRuntimeLevelSnapshotResponse {
|
||||
run_id: level.run_id,
|
||||
level_index: level.level_index,
|
||||
level_id: level.level_id,
|
||||
grid_size: level.grid_size,
|
||||
profile_id: level.profile_id,
|
||||
level_name: level.level_name,
|
||||
@@ -1933,14 +2024,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
}
|
||||
|
||||
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||
build_puzzle_form_seed_text_from_parts(
|
||||
payload
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref()),
|
||||
payload.work_description.as_deref(),
|
||||
payload.picture_description.as_deref(),
|
||||
)
|
||||
work_description: payload.work_description.as_deref(),
|
||||
picture_description: payload.picture_description.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_form_seed_text_from_parts(
|
||||
@@ -1948,20 +2039,11 @@ fn build_puzzle_form_seed_text_from_parts(
|
||||
work_description: Option<&str>,
|
||||
picture_description: Option<&str>,
|
||||
) -> String {
|
||||
let title = title.unwrap_or_default().trim();
|
||||
let work_description = work_description.unwrap_or_default().trim();
|
||||
let picture_description = picture_description.unwrap_or_default().trim();
|
||||
|
||||
[
|
||||
("作品名称", title),
|
||||
("作品描述", work_description),
|
||||
("画面描述", picture_description),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(label, value)| format!("{label}:{value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title,
|
||||
work_description,
|
||||
picture_description,
|
||||
})
|
||||
}
|
||||
|
||||
async fn save_puzzle_form_payload_before_compile(
|
||||
@@ -1971,7 +2053,7 @@ async fn save_puzzle_form_payload_before_compile(
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
now: i64,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<String, Response> {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
@@ -1981,26 +2063,101 @@ async fn save_puzzle_form_payload_before_compile(
|
||||
.or(payload.prompt_text.as_deref()),
|
||||
);
|
||||
if seed_text.trim().is_empty() {
|
||||
return Ok(());
|
||||
return Ok(session_id.to_string());
|
||||
}
|
||||
|
||||
state
|
||||
let save_result = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
|
||||
session_id: session_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
seed_text,
|
||||
seed_text: seed_text.clone(),
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map(|_| ());
|
||||
match save_result {
|
||||
Ok(()) => Ok(session_id.to_string()),
|
||||
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
|
||||
create_seeded_puzzle_session_when_form_save_missing(
|
||||
state,
|
||||
request_context,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
seed_text,
|
||||
now,
|
||||
&error,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(puzzle_error_response(
|
||||
request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_seeded_puzzle_session_when_form_save_missing(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
seed_text: String,
|
||||
now: i64,
|
||||
original_error: &SpacetimeClientError,
|
||||
) -> Result<String, Response> {
|
||||
let current_session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
if !current_session.seed_text.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
error = %original_error,
|
||||
"拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译"
|
||||
);
|
||||
return Ok(session_id.to_string());
|
||||
}
|
||||
|
||||
// 中文注释:旧 Maincloud 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。
|
||||
let replacement_session_id = build_prefixed_uuid_id("puzzle-session-");
|
||||
let replacement = state
|
||||
.spacetime_client()
|
||||
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
|
||||
session_id: replacement_session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
seed_text: seed_text.clone(),
|
||||
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
|
||||
welcome_message_text: build_puzzle_welcome_text(&seed_text),
|
||||
created_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
old_session_id = %session_id,
|
||||
new_session_id = %replacement.session_id,
|
||||
owner_user_id,
|
||||
error = %original_error,
|
||||
"拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session"
|
||||
);
|
||||
Ok(replacement.session_id)
|
||||
}
|
||||
|
||||
fn select_puzzle_level_for_api(
|
||||
@@ -2008,15 +2165,20 @@ fn select_puzzle_level_for_api(
|
||||
level_id: Option<&str>,
|
||||
) -> Result<PuzzleDraftLevelRecord, AppError> {
|
||||
let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
let level = normalized_level_id
|
||||
.and_then(|target_id| {
|
||||
draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|level| level.level_id == target_id)
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| draft.levels.first().cloned());
|
||||
if let Some(target_id) = normalized_level_id {
|
||||
return draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|level| level.level_id == target_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡不存在:{target_id}"),
|
||||
}))
|
||||
});
|
||||
}
|
||||
let level = draft.levels.first().cloned();
|
||||
level.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -2025,6 +2187,43 @@ fn select_puzzle_level_for_api(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_puzzle_level_records_from_module_json(
|
||||
value: &str,
|
||||
) -> Result<Vec<PuzzleDraftLevelRecord>, AppError> {
|
||||
let levels: Vec<module_puzzle::PuzzleDraftLevel> =
|
||||
serde_json::from_str(value).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡列表 JSON 非法:{error}"),
|
||||
}))
|
||||
})?;
|
||||
Ok(levels
|
||||
.into_iter()
|
||||
.map(|level| PuzzleDraftLevelRecord {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|candidate| PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: candidate.candidate_id,
|
||||
image_src: candidate.image_src,
|
||||
asset_id: candidate.asset_id,
|
||||
prompt: candidate.prompt,
|
||||
actual_prompt: candidate.actual_prompt,
|
||||
source_type: candidate.source_type,
|
||||
selected: candidate.selected,
|
||||
})
|
||||
.collect(),
|
||||
selected_candidate_id: level.selected_candidate_id,
|
||||
cover_image_src: level.cover_image_src,
|
||||
cover_asset_id: level.cover_asset_id,
|
||||
generation_status: level.generation_status,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn serialize_puzzle_levels_response(
|
||||
request_context: &RequestContext,
|
||||
levels: &[PuzzleDraftLevelResponse],
|
||||
@@ -2138,22 +2337,18 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
.ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?;
|
||||
let target_level = select_puzzle_level_for_api(&draft, None)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
|
||||
let image_prompt = prompt_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
Some(target_level.picture_description.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or(draft.summary.as_str());
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
&target_level.level_name,
|
||||
image_prompt,
|
||||
&image_prompt,
|
||||
reference_image_src,
|
||||
1,
|
||||
target_level.candidates.len(),
|
||||
@@ -2179,6 +2374,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: None,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2252,6 +2448,15 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
|
||||
matches!(error, SpacetimeClientError::Procedure(message) if
|
||||
message.contains("save_puzzle_form_draft")
|
||||
&& (message.contains("No such procedure")
|
||||
|| message.contains("不存在")
|
||||
|| message.contains("does not exist")
|
||||
|| message.contains("not found")))
|
||||
}
|
||||
|
||||
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
||||
let message = error.to_string();
|
||||
let provider = if message.contains("DashScope") || message.contains("dashscope") {
|
||||
@@ -2484,11 +2689,18 @@ async fn build_local_next_puzzle_run(
|
||||
);
|
||||
}
|
||||
|
||||
let source_session_id = payload.source_session_id.unwrap_or_default();
|
||||
if let Some(next_run) =
|
||||
build_same_work_local_next_puzzle_run(state, &run, &source_session_id, owner_user_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(next_run);
|
||||
}
|
||||
|
||||
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
|
||||
return Ok(build_next_run_from_puzzle_work(state, run, gallery_item));
|
||||
}
|
||||
|
||||
let source_session_id = payload.source_session_id.unwrap_or_default();
|
||||
if source_session_id.trim().is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -2551,6 +2763,7 @@ async fn build_local_next_puzzle_run(
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
level_id: None,
|
||||
levels_json: None,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2578,6 +2791,101 @@ async fn build_local_next_puzzle_run(
|
||||
))
|
||||
}
|
||||
|
||||
async fn build_same_work_local_next_puzzle_run(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
source_session_id: &str,
|
||||
owner_user_id: &str,
|
||||
) -> Result<Option<PuzzleRunRecord>, AppError> {
|
||||
if !should_use_same_work_next_level(run) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(work) = fetch_local_current_work_detail(state, run).await? {
|
||||
if let Some(level) = select_local_next_level(&work.levels, run) {
|
||||
let next_after_level =
|
||||
select_next_level_after_level_id(&work.levels, level.level_id.as_str())
|
||||
.map(|item| item.level_id.clone());
|
||||
return Ok(Some(build_next_run_from_draft_level(
|
||||
run.clone(),
|
||||
level,
|
||||
Some(work.profile_id),
|
||||
work.author_display_name,
|
||||
work.theme_tags,
|
||||
next_after_level,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let normalized_session_id = source_session_id.trim();
|
||||
if normalized_session_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(normalized_session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let Some(draft) = session.draft.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(level) = select_local_next_level(&draft.levels, run) {
|
||||
let next_after_level =
|
||||
select_next_level_after_level_id(&draft.levels, level.level_id.as_str())
|
||||
.map(|item| item.level_id.clone());
|
||||
return Ok(Some(build_next_run_from_draft_level(
|
||||
run.clone(),
|
||||
level,
|
||||
Some(run.entry_profile_id.clone()),
|
||||
"当前草稿".to_string(),
|
||||
draft.theme_tags.clone(),
|
||||
next_after_level,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn should_use_same_work_next_level(run: &PuzzleRunRecord) -> bool {
|
||||
run.next_level_mode == module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK
|
||||
|| run
|
||||
.next_level_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
async fn fetch_local_current_work_detail(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
|
||||
let profile_id = run
|
||||
.next_level_profile_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
run.current_level
|
||||
.as_ref()
|
||||
.map(|level| level.profile_id.as_str())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or(run.entry_profile_id.as_str());
|
||||
match state
|
||||
.spacetime_client()
|
||||
.get_puzzle_gallery_detail(profile_id.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(work) => Ok(Some(work)),
|
||||
Err(SpacetimeClientError::Procedure(message))
|
||||
if message.contains("不存在")
|
||||
|| message.contains("not found")
|
||||
|| message.contains("does not exist") =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_gallery_next_puzzle_work(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
@@ -2609,6 +2917,76 @@ fn pick_unused_puzzle_candidate<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
fn select_local_next_level<'a>(
|
||||
levels: &'a [PuzzleDraftLevelRecord],
|
||||
run: &PuzzleRunRecord,
|
||||
) -> Option<&'a PuzzleDraftLevelRecord> {
|
||||
if levels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(next_level_id) = run
|
||||
.next_level_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if let Some(level) = levels.iter().find(|level| level.level_id == next_level_id) {
|
||||
return Some(level);
|
||||
}
|
||||
}
|
||||
|
||||
let current_level = run.current_level.as_ref()?;
|
||||
let matched_index = levels
|
||||
.iter()
|
||||
.position(|level| {
|
||||
level.cover_image_src == current_level.cover_image_src
|
||||
&& level.level_name == current_level.level_name
|
||||
})
|
||||
.or_else(|| {
|
||||
current_level
|
||||
.level_index
|
||||
.checked_sub(1)
|
||||
.and_then(|index| ((index as usize) < levels.len()).then_some(index as usize))
|
||||
})?;
|
||||
levels.get(matched_index + 1)
|
||||
}
|
||||
|
||||
fn select_next_level_after_level_id<'a>(
|
||||
levels: &'a [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
) -> Option<&'a PuzzleDraftLevelRecord> {
|
||||
let matched_index = levels.iter().position(|level| level.level_id == level_id)?;
|
||||
levels.get(matched_index + 1)
|
||||
}
|
||||
|
||||
fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option<String> {
|
||||
level
|
||||
.cover_image_src
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
level
|
||||
.selected_candidate_id
|
||||
.as_ref()
|
||||
.and_then(|candidate_id| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.candidate_id == *candidate_id)
|
||||
})
|
||||
.map(|candidate| candidate.image_src.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| !candidate.image_src.trim().is_empty())
|
||||
.map(|candidate| candidate.image_src.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn build_next_run_from_puzzle_work(
|
||||
state: &AppState,
|
||||
run: PuzzleRunRecord,
|
||||
@@ -2654,6 +3032,34 @@ fn build_next_run_from_candidate(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_draft_level(
|
||||
mut run: PuzzleRunRecord,
|
||||
level: &PuzzleDraftLevelRecord,
|
||||
profile_id: Option<String>,
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
next_after_level_id: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
// 中文注释:当前关卡 id 必须取本次选中的目标 level,避免旧 run 的空值或脏值影响后续同作品接续。
|
||||
run.next_level_id = Some(level.level_id.clone());
|
||||
let fallback_profile_id = run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.map(|level| level.profile_id.clone())
|
||||
.unwrap_or_else(|| level.level_id.clone());
|
||||
build_next_run_from_parts_with_handoff(
|
||||
run,
|
||||
profile_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(fallback_profile_id),
|
||||
level.level_name.clone(),
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
resolve_level_cover_image_src(level),
|
||||
next_after_level_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_parts(
|
||||
run: PuzzleRunRecord,
|
||||
profile_id: String,
|
||||
@@ -2661,11 +3067,32 @@ fn build_next_run_from_parts(
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
build_next_run_from_parts_with_handoff(
|
||||
run,
|
||||
profile_id,
|
||||
level_name,
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
cover_image_src,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_parts_with_handoff(
|
||||
run: PuzzleRunRecord,
|
||||
profile_id: String,
|
||||
level_name: String,
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
next_after_level_id: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
let next_level_index = run.current_level_index + 1;
|
||||
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
|
||||
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
let current_level_id = run.next_level_id.clone();
|
||||
if !played_profile_ids.contains(&profile_id) {
|
||||
played_profile_ids.push(profile_id.clone());
|
||||
}
|
||||
@@ -2681,8 +3108,9 @@ fn build_next_run_from_parts(
|
||||
current_level: Some(PuzzleRuntimeLevelRecord {
|
||||
run_id: run.run_id,
|
||||
level_index: next_level_index,
|
||||
level_id: current_level_id,
|
||||
grid_size,
|
||||
profile_id,
|
||||
profile_id: profile_id.clone(),
|
||||
level_name,
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
@@ -2702,6 +3130,13 @@ fn build_next_run_from_parts(
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
next_level_mode: next_after_level_id
|
||||
.as_ref()
|
||||
.map(|_| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string())
|
||||
.unwrap_or_else(|| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()),
|
||||
next_level_profile_id: next_after_level_id.as_ref().map(|_| profile_id),
|
||||
next_level_id: next_after_level_id,
|
||||
recommended_next_works: Vec::new(),
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ use crate::{
|
||||
prompt::runtime_chat::{
|
||||
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
NpcChatTurnPromptInput, build_deterministic_chat_suggestions,
|
||||
build_deterministic_npc_reply, build_fallback_function_suggestions,
|
||||
build_fallback_npc_chat_suggestions, build_npc_chat_turn_reply_prompt,
|
||||
build_npc_chat_turn_suggestion_prompt,
|
||||
build_deterministic_hostile_breakoff_reply, build_deterministic_npc_reply,
|
||||
build_fallback_function_suggestions, build_fallback_npc_chat_suggestions,
|
||||
build_npc_chat_turn_reply_prompt, build_npc_chat_turn_suggestion_prompt,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
@@ -137,16 +137,26 @@ pub async fn stream_runtime_npc_chat_turn(
|
||||
let (npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
let npc_reply = build_deterministic_npc_reply(
|
||||
npc_name.as_str(),
|
||||
player_message.as_str(),
|
||||
payload.npc_initiates_conversation,
|
||||
);
|
||||
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|
||||
|| should_hostile_chat_breakoff_deterministically(
|
||||
let deterministic_hostile_breakoff =
|
||||
should_hostile_chat_breakoff_deterministically(
|
||||
player_message.as_str(),
|
||||
payload.chat_directive.as_ref(),
|
||||
Some(&payload.npc_state),
|
||||
);
|
||||
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|
||||
|| deterministic_hostile_breakoff;
|
||||
let npc_reply = if deterministic_hostile_breakoff {
|
||||
build_deterministic_hostile_breakoff_reply(
|
||||
npc_name.as_str(),
|
||||
player_message.as_str(),
|
||||
)
|
||||
} else {
|
||||
build_deterministic_npc_reply(
|
||||
npc_name.as_str(),
|
||||
player_message.as_str(),
|
||||
payload.npc_initiates_conversation,
|
||||
)
|
||||
};
|
||||
let suggestions = if force_exit {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -272,6 +282,7 @@ where
|
||||
|| should_hostile_chat_breakoff_deterministically(
|
||||
payload.player_message.as_str(),
|
||||
payload.chat_directive.as_ref(),
|
||||
Some(&payload.npc_state),
|
||||
);
|
||||
|
||||
if force_exit {
|
||||
@@ -618,6 +629,7 @@ fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool {
|
||||
fn should_hostile_chat_breakoff_deterministically(
|
||||
player_message: &str,
|
||||
chat_directive: Option<&Value>,
|
||||
npc_state: Option<&Value>,
|
||||
) -> bool {
|
||||
if !is_hostile_model_chat(chat_directive) {
|
||||
return false;
|
||||
@@ -631,6 +643,14 @@ fn should_hostile_chat_breakoff_deterministically(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 中文注释:模型建议不可用时,后端兜底仍按敌对聊天口径避免负面挑衅被拖成闲聊。
|
||||
if npc_state
|
||||
.and_then(|state| read_number_field(state, "chattedCount"))
|
||||
.is_some_and(|chatted_count| chatted_count >= 4.0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let hostile_break_words = [
|
||||
"动手",
|
||||
"开战",
|
||||
@@ -640,6 +660,18 @@ fn should_hostile_chat_breakoff_deterministically(
|
||||
"闭嘴",
|
||||
"少废话",
|
||||
"别挡路",
|
||||
"废话",
|
||||
"威胁",
|
||||
"找死",
|
||||
"送死",
|
||||
"住口",
|
||||
"让开",
|
||||
"滚开",
|
||||
"不退",
|
||||
"不会退",
|
||||
"别装",
|
||||
"骗子",
|
||||
"叛徒",
|
||||
];
|
||||
count_keyword_matches(player_message, &hostile_break_words) > 0
|
||||
}
|
||||
@@ -812,6 +844,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_chat_breakoff_fallback_triggers_on_negative_words() {
|
||||
let chat_directive = json!({
|
||||
"terminationMode": "hostile_model",
|
||||
"isHostileChat": true,
|
||||
});
|
||||
let npc_state = json!({ "chattedCount": 1 });
|
||||
|
||||
assert!(should_hostile_chat_breakoff_deterministically(
|
||||
"少废话,让开,不然现在就动手。",
|
||||
Some(&chat_directive),
|
||||
Some(&npc_state),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_chat_breakoff_fallback_triggers_after_four_turns() {
|
||||
let chat_directive = json!({
|
||||
"terminationMode": "hostile_model",
|
||||
"isHostileChat": true,
|
||||
});
|
||||
let npc_state = json!({ "chattedCount": 4 });
|
||||
|
||||
assert!(should_hostile_chat_breakoff_deterministically(
|
||||
"我还想再问一个问题。",
|
||||
Some(&chat_directive),
|
||||
Some(&npc_state),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_chat_breakoff_fallback_ignores_non_hostile_chat() {
|
||||
let chat_directive = json!({
|
||||
"terminationMode": "none",
|
||||
"isHostileChat": false,
|
||||
});
|
||||
let npc_state = json!({ "chattedCount": 6 });
|
||||
|
||||
assert!(!should_hostile_chat_breakoff_deterministically(
|
||||
"少废话,让开。",
|
||||
Some(&chat_directive),
|
||||
Some(&npc_state),
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn npc_chat_turn_prefers_request_snapshot_over_persisted_session() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
@@ -16,6 +16,10 @@ pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-";
|
||||
pub const PUZZLE_MIN_TAG_COUNT: usize = 3;
|
||||
pub const PUZZLE_MAX_TAG_COUNT: usize = 6;
|
||||
pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000;
|
||||
pub const PUZZLE_EXTEND_TIME_DURATION_MS: u64 = 60_000;
|
||||
pub const PUZZLE_NEXT_LEVEL_MODE_SAME_WORK: &str = "sameWork";
|
||||
pub const PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS: &str = "similarWorks";
|
||||
pub const PUZZLE_NEXT_LEVEL_MODE_NONE: &str = "none";
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -310,6 +314,8 @@ pub struct PuzzleBoardSnapshot {
|
||||
pub struct PuzzleRuntimeLevelSnapshot {
|
||||
pub run_id: String,
|
||||
pub level_index: u32,
|
||||
#[serde(default)]
|
||||
pub level_id: Option<String>,
|
||||
pub grid_size: u32,
|
||||
pub profile_id: String,
|
||||
pub level_name: String,
|
||||
@@ -343,7 +349,7 @@ pub struct PuzzleRuntimeLevelSnapshot {
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PuzzleRunSnapshot {
|
||||
pub run_id: String,
|
||||
pub entry_profile_id: String,
|
||||
@@ -354,10 +360,33 @@ pub struct PuzzleRunSnapshot {
|
||||
pub previous_level_tags: Vec<String>,
|
||||
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
#[serde(default = "default_puzzle_next_level_mode")]
|
||||
pub next_level_mode: String,
|
||||
#[serde(default)]
|
||||
pub next_level_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub next_level_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub recommended_next_works: Vec<PuzzleRecommendedNextWork>,
|
||||
#[serde(default)]
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PuzzleRecommendedNextWork {
|
||||
pub profile_id: String,
|
||||
pub level_name: String,
|
||||
pub author_display_name: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub similarity_score: f32,
|
||||
}
|
||||
|
||||
fn default_puzzle_next_level_mode() -> String {
|
||||
PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleAgentSessionCreateInput {
|
||||
@@ -423,6 +452,7 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub levels_json: Option<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
}
|
||||
@@ -906,22 +936,22 @@ pub fn build_form_draft_from_parts(
|
||||
) -> PuzzleResultDraft {
|
||||
let work_title = work_title.and_then(|value| normalize_required_string(&value));
|
||||
let work_description = work_description.and_then(|value| normalize_required_string(&value));
|
||||
let picture_description = picture_description.and_then(|value| normalize_required_string(&value));
|
||||
let picture_description =
|
||||
picture_description.and_then(|value| normalize_required_string(&value));
|
||||
let title_for_tags = work_title.as_deref().unwrap_or("");
|
||||
let picture_for_tags = picture_description.as_deref().unwrap_or("");
|
||||
let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags));
|
||||
if tags.is_empty() {
|
||||
tags = vec!["拼图".to_string(), "插画".to_string(), "清晰构图".to_string()];
|
||||
tags = vec![
|
||||
"拼图".to_string(),
|
||||
"插画".to_string(),
|
||||
"清晰构图".to_string(),
|
||||
];
|
||||
}
|
||||
let level_name = picture_description
|
||||
.as_deref()
|
||||
.map(|value| build_level_name_from_picture(value, &tags, 1))
|
||||
.or_else(|| work_title.clone())
|
||||
.unwrap_or_else(|| "未命名拼图".to_string());
|
||||
let summary = work_description.clone().unwrap_or_default();
|
||||
let level = PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: level_name.clone(),
|
||||
level_name: String::new(),
|
||||
picture_description: picture_description.clone().unwrap_or_default(),
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
@@ -934,7 +964,7 @@ pub fn build_form_draft_from_parts(
|
||||
PuzzleResultDraft {
|
||||
work_title: work_title.clone().unwrap_or_default(),
|
||||
work_description: summary.clone(),
|
||||
level_name,
|
||||
level_name: String::new(),
|
||||
summary,
|
||||
theme_tags: tags,
|
||||
forbidden_directives: Vec::new(),
|
||||
@@ -1538,6 +1568,42 @@ pub fn apply_puzzle_freeze_time(
|
||||
apply_puzzle_freeze_time_at(run, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn extend_failed_puzzle_time_at(
|
||||
run: &PuzzleRunSnapshot,
|
||||
now_ms: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
|
||||
let current_level = next_run
|
||||
.current_level
|
||||
.as_mut()
|
||||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Failed {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
|
||||
let total_consumed_before_extend = current_level
|
||||
.time_limit_ms
|
||||
.saturating_sub(PUZZLE_EXTEND_TIME_DURATION_MS);
|
||||
current_level.status = PuzzleRuntimeLevelStatus::Playing;
|
||||
current_level.elapsed_ms = None;
|
||||
current_level.cleared_at_ms = None;
|
||||
current_level.remaining_ms = PUZZLE_EXTEND_TIME_DURATION_MS;
|
||||
current_level.started_at_ms = now_ms.saturating_sub(total_consumed_before_extend);
|
||||
current_level.paused_accumulated_ms = 0;
|
||||
current_level.pause_started_at_ms = None;
|
||||
current_level.freeze_accumulated_ms = 0;
|
||||
current_level.freeze_started_at_ms = None;
|
||||
current_level.freeze_until_ms = None;
|
||||
|
||||
Ok(next_run)
|
||||
}
|
||||
|
||||
pub fn extend_failed_puzzle_time(
|
||||
run: &PuzzleRunSnapshot,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
extend_failed_puzzle_time_at(run, current_unix_ms())
|
||||
}
|
||||
|
||||
pub fn build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||||
build_initial_board_with_seed(grid_size, 0)
|
||||
}
|
||||
@@ -1625,6 +1691,10 @@ pub fn start_run_with_shuffle_seed_at(
|
||||
current_level: Some(PuzzleRuntimeLevelSnapshot {
|
||||
run_id,
|
||||
level_index: cleared_level_count + 1,
|
||||
level_id: entry_profile
|
||||
.levels
|
||||
.first()
|
||||
.map(|level| level.level_id.clone()),
|
||||
grid_size,
|
||||
profile_id: entry_profile.profile_id.clone(),
|
||||
level_name: entry_profile.level_name.clone(),
|
||||
@@ -1646,6 +1716,10 @@ pub fn start_run_with_shuffle_seed_at(
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
next_level_mode: default_puzzle_next_level_mode(),
|
||||
next_level_profile_id: None,
|
||||
next_level_id: None,
|
||||
recommended_next_works: Vec::new(),
|
||||
leaderboard_entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -1886,6 +1960,10 @@ pub fn advance_next_level_at(
|
||||
current_level: Some(PuzzleRuntimeLevelSnapshot {
|
||||
run_id: run.run_id.clone(),
|
||||
level_index: run.current_level_index + 1,
|
||||
level_id: next_profile
|
||||
.levels
|
||||
.first()
|
||||
.map(|level| level.level_id.clone()),
|
||||
grid_size: next_grid_size,
|
||||
profile_id: next_profile.profile_id.clone(),
|
||||
level_name: next_profile.level_name.clone(),
|
||||
@@ -1907,15 +1985,98 @@ pub fn advance_next_level_at(
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
next_level_mode: default_puzzle_next_level_mode(),
|
||||
next_level_profile_id: None,
|
||||
next_level_id: None,
|
||||
recommended_next_works: Vec::new(),
|
||||
leaderboard_entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_profile_level_after_index(
|
||||
profile: &PuzzleWorkProfile,
|
||||
current_level_index: u32,
|
||||
) -> Option<PuzzleDraftLevel> {
|
||||
if current_level_index == 0 {
|
||||
return None;
|
||||
}
|
||||
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
|
||||
.unwrap_or_else(|_| profile.levels.clone());
|
||||
normalized_levels.get(current_level_index as usize).cloned()
|
||||
}
|
||||
|
||||
pub fn selected_profile_level_after_runtime_level(
|
||||
profile: &PuzzleWorkProfile,
|
||||
current_level: &PuzzleRuntimeLevelSnapshot,
|
||||
) -> Option<PuzzleDraftLevel> {
|
||||
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
|
||||
.unwrap_or_else(|_| profile.levels.clone());
|
||||
if normalized_levels.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let matched_index = current_level
|
||||
.level_id
|
||||
.as_ref()
|
||||
.and_then(|level_id| {
|
||||
normalized_levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == *level_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
current_level
|
||||
.cover_image_src
|
||||
.as_ref()
|
||||
.and_then(|cover_image_src| {
|
||||
normalized_levels.iter().position(|level| {
|
||||
level.cover_image_src.as_ref() == Some(cover_image_src)
|
||||
&& level.level_name == current_level.level_name
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
normalized_levels.iter().position(|level| {
|
||||
level.level_name == current_level.level_name
|
||||
&& level.cover_image_src == current_level.cover_image_src
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
current_level.level_index.checked_sub(1).and_then(|index| {
|
||||
((index as usize) < normalized_levels.len()).then_some(index as usize)
|
||||
})
|
||||
})?;
|
||||
|
||||
normalized_levels.get(matched_index + 1).cloned()
|
||||
}
|
||||
|
||||
pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str) -> Option<usize> {
|
||||
let target_level_id = normalize_required_string(level_id)?;
|
||||
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
|
||||
.unwrap_or_else(|_| profile.levels.clone());
|
||||
normalized_levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
}
|
||||
|
||||
pub fn select_next_profile<'a>(
|
||||
current_profile: &PuzzleWorkProfile,
|
||||
played_profile_ids: &[String],
|
||||
candidates: &'a [PuzzleWorkProfile],
|
||||
) -> Option<&'a PuzzleWorkProfile> {
|
||||
select_next_profiles(current_profile, played_profile_ids, candidates, 1)
|
||||
.into_iter()
|
||||
.next()
|
||||
}
|
||||
|
||||
pub fn select_next_profiles<'a>(
|
||||
current_profile: &PuzzleWorkProfile,
|
||||
played_profile_ids: &[String],
|
||||
candidates: &'a [PuzzleWorkProfile],
|
||||
limit: usize,
|
||||
) -> Vec<&'a PuzzleWorkProfile> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut available = candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
@@ -1936,23 +2097,25 @@ pub fn select_next_profile<'a>(
|
||||
available.retain(|candidate| candidate.profile_id != *last_played);
|
||||
}
|
||||
|
||||
available.into_iter().max_by(|left, right| {
|
||||
available.sort_by(|left, right| {
|
||||
let left_score = recommendation_score(current_profile, left);
|
||||
let right_score = recommendation_score(current_profile, right);
|
||||
left_score
|
||||
.partial_cmp(&right_score)
|
||||
right_score
|
||||
.partial_cmp(&left_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| {
|
||||
tag_similarity_score(¤t_profile.theme_tags, &left.theme_tags)
|
||||
tag_similarity_score(¤t_profile.theme_tags, &right.theme_tags)
|
||||
.partial_cmp(&tag_similarity_score(
|
||||
¤t_profile.theme_tags,
|
||||
&right.theme_tags,
|
||||
&left.theme_tags,
|
||||
))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.then_with(|| right.play_count.cmp(&left.play_count))
|
||||
.then_with(|| left.updated_at_micros.cmp(&right.updated_at_micros))
|
||||
})
|
||||
.then_with(|| left.play_count.cmp(&right.play_count))
|
||||
.then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros))
|
||||
});
|
||||
available.truncate(limit);
|
||||
available
|
||||
}
|
||||
|
||||
pub fn recommendation_score(
|
||||
@@ -1983,10 +2146,169 @@ pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32
|
||||
if union <= f32::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
intersection / union
|
||||
let lexical_score = intersection / union;
|
||||
// 中文注释:优先复用 RPG build 标签的属性亲和度语义模型;拼图自有标签未命中时保留 Jaccard 兜底。
|
||||
rpg_build_tag_set_similarity(&left_set, &right_set)
|
||||
.map(|semantic_score| semantic_score.max(lexical_score))
|
||||
.unwrap_or(lexical_score)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RpgBuildTagSemanticDefinition {
|
||||
category: &'static str,
|
||||
affinity: [f32; 6],
|
||||
}
|
||||
|
||||
fn rpg_affinity(strength: f32, agility: f32, intelligence: f32, spirit: f32) -> [f32; 6] {
|
||||
[
|
||||
strength * 0.72 + spirit * 0.28,
|
||||
agility * 0.88 + intelligence * 0.12,
|
||||
intelligence * 0.78 + agility * 0.22,
|
||||
strength * 0.62 + agility * 0.18 + intelligence * 0.2,
|
||||
spirit * 0.72 + intelligence * 0.28,
|
||||
spirit * 0.74 + strength * 0.26,
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve_rpg_build_tag_semantic(tag: &str) -> Option<RpgBuildTagSemanticDefinition> {
|
||||
let normalized = tag.trim().to_lowercase();
|
||||
let value = normalized.as_str();
|
||||
let definition = match value {
|
||||
"quickblade" | "快剑" | "快刀" | "决斗者" => {
|
||||
("style", rpg_affinity(0.35, 1.0, 0.1, 0.05))
|
||||
}
|
||||
"combo" | "连段" | "连击" | "连锁" => ("style", rpg_affinity(0.3, 0.92, 0.18, 0.08)),
|
||||
"dash" | "突进" | "冲锋" => ("style", rpg_affinity(0.45, 0.95, 0.0, 0.0)),
|
||||
"pursuit" | "追击" => ("style", rpg_affinity(0.38, 0.88, 0.08, 0.02)),
|
||||
"swiftstrike" | "快袭" | "刺袭" | "伏击" => {
|
||||
("style", rpg_affinity(0.22, 0.98, 0.12, 0.04))
|
||||
}
|
||||
"ranged" | "远射" | "射击" | "箭矢" => {
|
||||
("style", rpg_affinity(0.18, 0.82, 0.34, 0.08))
|
||||
}
|
||||
"guerrilla" | "游击" | "骚扰" => ("style", rpg_affinity(0.24, 0.9, 0.28, 0.12)),
|
||||
"mobility" | "机动" | "敏捷" | "灵活" => {
|
||||
("style", rpg_affinity(0.18, 1.0, 0.08, 0.08))
|
||||
}
|
||||
"windrun" | "风行" | "疾行" => ("style", rpg_affinity(0.08, 1.0, 0.1, 0.1)),
|
||||
"heavyhit" | "重击" => ("style", rpg_affinity(1.0, 0.28, 0.02, 0.04)),
|
||||
"burst" | "爆发" => ("style", rpg_affinity(0.72, 0.58, 0.36, 0.08)),
|
||||
"armorbreak" | "破甲" => ("style", rpg_affinity(0.92, 0.28, 0.08, 0.02)),
|
||||
"pressure" | "压制" => ("style", rpg_affinity(0.62, 0.64, 0.1, 0.08)),
|
||||
"bloodrush" | "压血" => ("resource", rpg_affinity(0.84, 0.54, 0.04, 0.18)),
|
||||
"guard" | "守御" | "守卫" | "防御" => {
|
||||
("defense", rpg_affinity(0.7, 0.18, 0.04, 0.72))
|
||||
}
|
||||
"barrier" | "护体" | "护罩" | "护盾" => {
|
||||
("defense", rpg_affinity(0.48, 0.08, 0.2, 0.92))
|
||||
}
|
||||
"heavyarmor" | "重甲" => ("defense", rpg_affinity(0.88, 0.04, 0.02, 0.54)),
|
||||
"counter" | "反击" | "回击" => ("defense", rpg_affinity(0.66, 0.46, 0.14, 0.36)),
|
||||
"banish" | "镇邪" => ("defense", rpg_affinity(0.24, 0.06, 0.54, 0.88)),
|
||||
"caster" | "法修" | "法师" => ("element", rpg_affinity(0.0, 0.1, 1.0, 0.6)),
|
||||
"mana" | "法力" => ("resource", rpg_affinity(0.02, 0.08, 0.94, 0.74)),
|
||||
"thunder" | "雷法" => ("element", rpg_affinity(0.06, 0.24, 0.96, 0.42)),
|
||||
"formation" | "符阵" | "法阵" => ("element", rpg_affinity(0.08, 0.12, 0.82, 0.96)),
|
||||
"control" | "控场" | "控制" => ("style", rpg_affinity(0.12, 0.34, 0.78, 0.72)),
|
||||
"overload" | "过载" => ("resource", rpg_affinity(0.14, 0.18, 0.92, 0.38)),
|
||||
"heal" | "回复" | "治疗" => ("resource", rpg_affinity(0.02, 0.08, 0.56, 1.0)),
|
||||
"support" | "护持" | "支援" | "祝福" => {
|
||||
("resource", rpg_affinity(0.14, 0.14, 0.58, 0.98))
|
||||
}
|
||||
"sustain" | "续战" => ("resource", rpg_affinity(0.34, 0.18, 0.22, 0.9)),
|
||||
"fate" | "命纹" => ("flow", rpg_affinity(0.08, 0.22, 0.72, 0.84)),
|
||||
"fortune" | "机缘" => ("flow", rpg_affinity(0.06, 0.34, 0.7, 0.78)),
|
||||
"cooldown" | "冷却" => ("resource", rpg_affinity(0.04, 0.46, 0.82, 0.4)),
|
||||
"command" | "统御" => ("flow", rpg_affinity(0.38, 0.26, 0.72, 0.82)),
|
||||
"balanced" | "均衡" | "平衡" | "全能" => {
|
||||
("flow", rpg_affinity(0.58, 0.58, 0.58, 0.58))
|
||||
}
|
||||
"craft" | "工巧" | "工艺" => ("craft", rpg_affinity(0.24, 0.16, 0.74, 0.5)),
|
||||
"alchemy" | "炼药" | "药剂" => ("craft", rpg_affinity(0.08, 0.16, 0.84, 0.76)),
|
||||
"vanguard" | "先锋" => ("flow", rpg_affinity(0.82, 0.44, 0.08, 0.34)),
|
||||
"berserk" | "狂战" => ("flow", rpg_affinity(0.98, 0.42, 0.0, 0.22)),
|
||||
"spellblade" | "法剑" => ("flow", rpg_affinity(0.42, 0.42, 0.88, 0.38)),
|
||||
"paladin" | "圣佑" | "圣骑士" => ("flow", rpg_affinity(0.58, 0.12, 0.42, 0.96)),
|
||||
"fortress" | "堡垒" => ("flow", rpg_affinity(0.94, 0.04, 0.08, 0.82)),
|
||||
"starter" | "起手" => ("flow", rpg_affinity(0.42, 0.42, 0.42, 0.42)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(RpgBuildTagSemanticDefinition {
|
||||
category: definition.0,
|
||||
affinity: definition.1,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_affinity_dot(left: [f32; 6], right: [f32; 6]) -> f32 {
|
||||
let left_magnitude = left.iter().map(|value| value * value).sum::<f32>().sqrt();
|
||||
let right_magnitude = right.iter().map(|value| value * value).sum::<f32>().sqrt();
|
||||
if left_magnitude <= 0.0001 || right_magnitude <= 0.0001 {
|
||||
return 0.0;
|
||||
}
|
||||
left.iter()
|
||||
.zip(right.iter())
|
||||
.map(|(left_value, right_value)| {
|
||||
(left_value / left_magnitude) * (right_value / right_magnitude)
|
||||
})
|
||||
.sum::<f32>()
|
||||
}
|
||||
|
||||
fn rpg_build_tag_similarity(
|
||||
left: RpgBuildTagSemanticDefinition,
|
||||
right: RpgBuildTagSemanticDefinition,
|
||||
) -> f32 {
|
||||
let category_bonus = if left.category == right.category {
|
||||
0.08
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(normalized_affinity_dot(left.affinity, right.affinity) + category_bonus).min(1.0)
|
||||
}
|
||||
|
||||
fn rpg_build_tag_directional_similarity(
|
||||
left: &[RpgBuildTagSemanticDefinition],
|
||||
right: &[RpgBuildTagSemanticDefinition],
|
||||
) -> f32 {
|
||||
if left.is_empty() || right.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let total = left
|
||||
.iter()
|
||||
.map(|left_definition| {
|
||||
right
|
||||
.iter()
|
||||
.map(|right_definition| {
|
||||
rpg_build_tag_similarity(*left_definition, *right_definition)
|
||||
})
|
||||
.fold(0.0_f32, f32::max)
|
||||
})
|
||||
.sum::<f32>();
|
||||
total / left.len() as f32
|
||||
}
|
||||
|
||||
fn rpg_build_tag_set_similarity(
|
||||
left_tags: &BTreeSet<String>,
|
||||
right_tags: &BTreeSet<String>,
|
||||
) -> Option<f32> {
|
||||
let left_definitions = left_tags
|
||||
.iter()
|
||||
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
|
||||
.collect::<Vec<_>>();
|
||||
let right_definitions = right_tags
|
||||
.iter()
|
||||
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
|
||||
.collect::<Vec<_>>();
|
||||
if left_definitions.is_empty() || right_definitions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
(rpg_build_tag_directional_similarity(&left_definitions, &right_definitions)
|
||||
+ rpg_build_tag_directional_similarity(&right_definitions, &left_definitions))
|
||||
/ 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn normalize_theme_tags(tags: Vec<String>) -> Vec<String> {
|
||||
let alias_map = BTreeMap::from([
|
||||
("蒸汽", "蒸汽城市"),
|
||||
@@ -2172,7 +2494,7 @@ fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec<String>
|
||||
|
||||
fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool {
|
||||
matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked)
|
||||
&& matches!(
|
||||
|| matches!(
|
||||
anchor_pack.visual_subject.status,
|
||||
PuzzleAnchorStatus::Locked
|
||||
)
|
||||
@@ -2902,6 +3224,24 @@ mod tests {
|
||||
assert_eq!(resolve_puzzle_grid_size(3), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_draft_preserves_partial_initial_fields() {
|
||||
let seed_text = "作品名称:月台拼图\n作品描述:";
|
||||
let anchor_pack = infer_anchor_pack(seed_text, Some(seed_text));
|
||||
let draft = build_form_draft_from_seed(&anchor_pack, Some(seed_text));
|
||||
let form_draft = draft.form_draft.expect("form draft should exist");
|
||||
|
||||
assert_eq!(form_draft.work_title.as_deref(), Some("月台拼图"));
|
||||
assert_eq!(form_draft.work_description, None);
|
||||
assert_eq!(form_draft.picture_description, None);
|
||||
assert_eq!(draft.work_title, "月台拼图");
|
||||
assert_eq!(draft.work_description, "");
|
||||
assert_eq!(draft.level_name, "");
|
||||
assert_eq!(draft.levels[0].level_name, "");
|
||||
assert_eq!(draft.anchor_pack.theme_promise.value, "月台拼图");
|
||||
assert_eq!(draft.anchor_pack.visual_subject.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_theme_tags_dedups_aliases() {
|
||||
assert_eq!(
|
||||
@@ -2993,7 +3333,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_similarity_score_uses_jaccard() {
|
||||
fn tag_similarity_score_uses_jaccard_fallback() {
|
||||
let score = tag_similarity_score(
|
||||
&["蒸汽城市".to_string(), "雨夜".to_string()],
|
||||
&["蒸汽城市".to_string(), "猫咪".to_string()],
|
||||
@@ -3001,6 +3341,13 @@ mod tests {
|
||||
assert!((score - 0.3333).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_similarity_score_prefers_rpg_build_semantic_affinity() {
|
||||
let score = tag_similarity_score(&["快剑".to_string()], &["连击".to_string()]);
|
||||
|
||||
assert!(score > 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_next_profile_prefers_same_tags_and_author() {
|
||||
let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::puzzle_works::PuzzleWorkSummaryResponse;
|
||||
use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -11,5 +11,5 @@ pub struct PuzzleGalleryResponse {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PuzzleGalleryDetailResponse {
|
||||
pub item: PuzzleWorkSummaryResponse,
|
||||
pub item: PuzzleWorkProfileResponse,
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ pub struct PuzzleBoardSnapshotResponse {
|
||||
pub struct PuzzleRuntimeLevelSnapshotResponse {
|
||||
pub run_id: String,
|
||||
pub level_index: u32,
|
||||
#[serde(default)]
|
||||
pub level_id: Option<String>,
|
||||
pub grid_size: u32,
|
||||
pub profile_id: String,
|
||||
pub level_name: String,
|
||||
@@ -139,6 +141,18 @@ pub struct PuzzleRuntimeLevelSnapshotResponse {
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PuzzleRecommendedNextWorkResponse {
|
||||
pub profile_id: String,
|
||||
pub level_name: String,
|
||||
pub author_display_name: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
pub similarity_score: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PuzzleRunSnapshotResponse {
|
||||
@@ -154,6 +168,14 @@ pub struct PuzzleRunSnapshotResponse {
|
||||
#[serde(default)]
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub next_level_mode: String,
|
||||
#[serde(default)]
|
||||
pub next_level_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub next_level_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub recommended_next_works: Vec<PuzzleRecommendedNextWorkResponse>,
|
||||
#[serde(default)]
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryResponse>,
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct PuzzleWorkSummaryResponse {
|
||||
#[serde(default)]
|
||||
pub recent_play_count_7d: u32,
|
||||
pub publish_ready: bool,
|
||||
#[serde(default)]
|
||||
pub levels: Vec<PuzzleDraftLevelResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -56,7 +58,6 @@ pub struct PuzzleWorkProfileResponse {
|
||||
#[serde(flatten)]
|
||||
pub summary: PuzzleWorkSummaryResponse,
|
||||
pub anchor_pack: PuzzleAnchorPackResponse,
|
||||
pub levels: Vec<PuzzleDraftLevelResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -34,13 +34,14 @@ pub use mapper::{
|
||||
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
|
||||
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord,
|
||||
ResolveNpcBattleInteractionInput,
|
||||
};
|
||||
|
||||
pub mod ai;
|
||||
|
||||
@@ -2459,6 +2459,14 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
|
||||
.current_level
|
||||
.map(map_puzzle_runtime_level_snapshot),
|
||||
recommended_next_profile_id: snapshot.recommended_next_profile_id,
|
||||
next_level_mode: snapshot.next_level_mode,
|
||||
next_level_profile_id: snapshot.next_level_profile_id,
|
||||
next_level_id: snapshot.next_level_id,
|
||||
recommended_next_works: snapshot
|
||||
.recommended_next_works
|
||||
.into_iter()
|
||||
.map(map_puzzle_recommended_next_work)
|
||||
.collect(),
|
||||
leaderboard_entries: snapshot
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
@@ -2467,12 +2475,26 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_recommended_next_work(
|
||||
snapshot: module_puzzle::PuzzleRecommendedNextWork,
|
||||
) -> PuzzleRecommendedNextWorkRecord {
|
||||
PuzzleRecommendedNextWorkRecord {
|
||||
profile_id: snapshot.profile_id,
|
||||
level_name: snapshot.level_name,
|
||||
author_display_name: snapshot.author_display_name,
|
||||
theme_tags: snapshot.theme_tags,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
similarity_score: snapshot.similarity_score,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_runtime_level_snapshot(
|
||||
snapshot: DomainPuzzleRuntimeLevelSnapshot,
|
||||
) -> PuzzleRuntimeLevelRecord {
|
||||
PuzzleRuntimeLevelRecord {
|
||||
run_id: snapshot.run_id,
|
||||
level_index: snapshot.level_index,
|
||||
level_id: snapshot.level_id,
|
||||
grid_size: snapshot.grid_size,
|
||||
profile_id: snapshot.profile_id,
|
||||
level_name: snapshot.level_name,
|
||||
@@ -4400,6 +4422,7 @@ pub struct PuzzleGeneratedImagesSaveRecordInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub levels_json: Option<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
}
|
||||
@@ -4739,10 +4762,21 @@ pub struct PuzzleBoardRecord {
|
||||
pub all_tiles_resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PuzzleRecommendedNextWorkRecord {
|
||||
pub profile_id: String,
|
||||
pub level_name: String,
|
||||
pub author_display_name: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub similarity_score: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleRuntimeLevelRecord {
|
||||
pub run_id: String,
|
||||
pub level_index: u32,
|
||||
pub level_id: Option<String>,
|
||||
pub grid_size: u32,
|
||||
pub profile_id: String,
|
||||
pub level_name: String,
|
||||
@@ -4764,7 +4798,7 @@ pub struct PuzzleRuntimeLevelRecord {
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PuzzleRunRecord {
|
||||
pub run_id: String,
|
||||
pub entry_profile_id: String,
|
||||
@@ -4775,6 +4809,10 @@ pub struct PuzzleRunRecord {
|
||||
pub previous_level_tags: Vec<String>,
|
||||
pub current_level: Option<PuzzleRuntimeLevelRecord>,
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
pub next_level_mode: String,
|
||||
pub next_level_profile_id: Option<String>,
|
||||
pub next_level_id: Option<String>,
|
||||
pub recommended_next_works: Vec<PuzzleRecommendedNextWorkRecord>,
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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::database_migration_import_chunks_clear_input_type::DatabaseMigrationImportChunksClearInput;
|
||||
use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ClearDatabaseMigrationImportChunksArgs {
|
||||
pub input: DatabaseMigrationImportChunksClearInput,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for ClearDatabaseMigrationImportChunksArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `clear_database_migration_import_chunks`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait clear_database_migration_import_chunks {
|
||||
fn clear_database_migration_import_chunks(&self, input: DatabaseMigrationImportChunksClearInput,
|
||||
) {
|
||||
self.clear_database_migration_import_chunks_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn clear_database_migration_import_chunks_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl clear_database_migration_import_chunks for super::RemoteProcedures {
|
||||
fn clear_database_migration_import_chunks_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunksClearInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>(
|
||||
"clear_database_migration_import_chunks",
|
||||
ClearDatabaseMigrationImportChunksArgs { input, },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 DatabaseMigrationImportChunkInput {
|
||||
pub upload_id: String,
|
||||
pub chunk_index: u32,
|
||||
pub chunk_count: u32,
|
||||
pub chunk: String,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for DatabaseMigrationImportChunkInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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 DatabaseMigrationImportChunk {
|
||||
pub chunk_key: String,
|
||||
pub upload_id: String,
|
||||
pub chunk_index: u32,
|
||||
pub chunk_count: u32,
|
||||
pub operator_identity: __sdk::Identity,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub chunk: String,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for DatabaseMigrationImportChunk {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
|
||||
/// Column accessor struct for the table `DatabaseMigrationImportChunk`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct DatabaseMigrationImportChunkCols {
|
||||
pub chunk_key: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, String>,
|
||||
pub upload_id: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, String>,
|
||||
pub chunk_index: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, u32>,
|
||||
pub chunk_count: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, u32>,
|
||||
pub operator_identity: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, __sdk::Identity>,
|
||||
pub created_at: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, __sdk::Timestamp>,
|
||||
pub chunk: __sdk::__query_builder::Col<DatabaseMigrationImportChunk, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for DatabaseMigrationImportChunk {
|
||||
type Cols = DatabaseMigrationImportChunkCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
DatabaseMigrationImportChunkCols {
|
||||
chunk_key: __sdk::__query_builder::Col::new(table_name, "chunk_key"),
|
||||
upload_id: __sdk::__query_builder::Col::new(table_name, "upload_id"),
|
||||
chunk_index: __sdk::__query_builder::Col::new(table_name, "chunk_index"),
|
||||
chunk_count: __sdk::__query_builder::Col::new(table_name, "chunk_count"),
|
||||
operator_identity: __sdk::__query_builder::Col::new(table_name, "operator_identity"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
chunk: __sdk::__query_builder::Col::new(table_name, "chunk"),
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `DatabaseMigrationImportChunk`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct DatabaseMigrationImportChunkIxCols {
|
||||
pub chunk_key: __sdk::__query_builder::IxCol<DatabaseMigrationImportChunk, String>,
|
||||
pub upload_id: __sdk::__query_builder::IxCol<DatabaseMigrationImportChunk, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for DatabaseMigrationImportChunk {
|
||||
type IxCols = DatabaseMigrationImportChunkIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
DatabaseMigrationImportChunkIxCols {
|
||||
chunk_key: __sdk::__query_builder::IxCol::new(table_name, "chunk_key"),
|
||||
upload_id: __sdk::__query_builder::IxCol::new(table_name, "upload_id"),
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for DatabaseMigrationImportChunk {}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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 DatabaseMigrationImportChunksClearInput {
|
||||
pub upload_id: String,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for DatabaseMigrationImportChunksClearInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 DatabaseMigrationImportChunksInput {
|
||||
pub upload_id: String,
|
||||
pub include_tables: Vec::<String>,
|
||||
pub replace_existing: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for DatabaseMigrationImportChunksInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{
|
||||
__ws,
|
||||
};
|
||||
|
||||
use super::database_migration_export_input_type::DatabaseMigrationExportInput;
|
||||
use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult;
|
||||
use super::database_migration_export_input_type::DatabaseMigrationExportInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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::database_migration_procedure_result_type::DatabaseMigrationProcedureResult;
|
||||
use super::database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ImportDatabaseMigrationFromChunksArgs {
|
||||
pub input: DatabaseMigrationImportChunksInput,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for ImportDatabaseMigrationFromChunksArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `import_database_migration_from_chunks`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait import_database_migration_from_chunks {
|
||||
fn import_database_migration_from_chunks(&self, input: DatabaseMigrationImportChunksInput,
|
||||
) {
|
||||
self.import_database_migration_from_chunks_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn import_database_migration_from_chunks_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl import_database_migration_from_chunks for super::RemoteProcedures {
|
||||
fn import_database_migration_from_chunks_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>(
|
||||
"import_database_migration_from_chunks",
|
||||
ImportDatabaseMigrationFromChunksArgs { input, },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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::database_migration_procedure_result_type::DatabaseMigrationProcedureResult;
|
||||
use super::database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ImportDatabaseMigrationIncrementalFromChunksArgs {
|
||||
pub input: DatabaseMigrationImportChunksInput,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for ImportDatabaseMigrationIncrementalFromChunksArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `import_database_migration_incremental_from_chunks`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait import_database_migration_incremental_from_chunks {
|
||||
fn import_database_migration_incremental_from_chunks(&self, input: DatabaseMigrationImportChunksInput,
|
||||
) {
|
||||
self.import_database_migration_incremental_from_chunks_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn import_database_migration_incremental_from_chunks_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl import_database_migration_incremental_from_chunks for super::RemoteProcedures {
|
||||
fn import_database_migration_incremental_from_chunks_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunksInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>(
|
||||
"import_database_migration_incremental_from_chunks",
|
||||
ImportDatabaseMigrationIncrementalFromChunksArgs { input, },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,10 @@ pub mod custom_world_works_list_input_type;
|
||||
pub mod custom_world_works_list_result_type;
|
||||
pub mod database_migration_authorize_operator_input_type;
|
||||
pub mod database_migration_export_input_type;
|
||||
pub mod database_migration_import_chunk_type;
|
||||
pub mod database_migration_import_chunk_input_type;
|
||||
pub mod database_migration_import_chunks_clear_input_type;
|
||||
pub mod database_migration_import_chunks_input_type;
|
||||
pub mod database_migration_import_input_type;
|
||||
pub mod database_migration_operator_type;
|
||||
pub mod database_migration_operator_procedure_result_type;
|
||||
@@ -410,6 +414,7 @@ pub mod authorize_database_migration_operator_procedure;
|
||||
pub mod begin_story_session_and_return_procedure;
|
||||
pub mod bind_asset_object_to_entity_and_return_procedure;
|
||||
pub mod cancel_ai_task_and_return_procedure;
|
||||
pub mod clear_database_migration_import_chunks_procedure;
|
||||
pub mod clear_platform_browse_history_and_return_procedure;
|
||||
pub mod compile_big_fish_draft_procedure;
|
||||
pub mod compile_custom_world_published_profile_procedure;
|
||||
@@ -464,7 +469,9 @@ pub mod get_runtime_snapshot_procedure;
|
||||
pub mod get_story_session_state_procedure;
|
||||
pub mod grant_player_progression_experience_and_return_procedure;
|
||||
pub mod import_auth_store_snapshot_procedure;
|
||||
pub mod import_database_migration_from_chunks_procedure;
|
||||
pub mod import_database_migration_from_file_procedure;
|
||||
pub mod import_database_migration_incremental_from_chunks_procedure;
|
||||
pub mod import_database_migration_incremental_from_file_procedure;
|
||||
pub mod list_asset_history_and_return_procedure;
|
||||
pub mod list_big_fish_works_procedure;
|
||||
@@ -480,6 +487,7 @@ pub mod publish_big_fish_game_procedure;
|
||||
pub mod publish_custom_world_profile_and_return_procedure;
|
||||
pub mod publish_custom_world_world_procedure;
|
||||
pub mod publish_puzzle_work_procedure;
|
||||
pub mod put_database_migration_import_chunk_procedure;
|
||||
pub mod record_big_fish_like_procedure;
|
||||
pub mod record_big_fish_play_procedure;
|
||||
pub mod record_custom_world_profile_like_procedure;
|
||||
@@ -670,6 +678,10 @@ pub use custom_world_works_list_input_type::CustomWorldWorksListInput;
|
||||
pub use custom_world_works_list_result_type::CustomWorldWorksListResult;
|
||||
pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput;
|
||||
pub use database_migration_export_input_type::DatabaseMigrationExportInput;
|
||||
pub use database_migration_import_chunk_type::DatabaseMigrationImportChunk;
|
||||
pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput;
|
||||
pub use database_migration_import_chunks_clear_input_type::DatabaseMigrationImportChunksClearInput;
|
||||
pub use database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput;
|
||||
pub use database_migration_import_input_type::DatabaseMigrationImportInput;
|
||||
pub use database_migration_operator_type::DatabaseMigrationOperator;
|
||||
pub use database_migration_operator_procedure_result_type::DatabaseMigrationOperatorProcedureResult;
|
||||
@@ -919,6 +931,7 @@ pub use authorize_database_migration_operator_procedure::authorize_database_migr
|
||||
pub use begin_story_session_and_return_procedure::begin_story_session_and_return;
|
||||
pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return;
|
||||
pub use cancel_ai_task_and_return_procedure::cancel_ai_task_and_return;
|
||||
pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks;
|
||||
pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return;
|
||||
pub use compile_big_fish_draft_procedure::compile_big_fish_draft;
|
||||
pub use compile_custom_world_published_profile_procedure::compile_custom_world_published_profile;
|
||||
@@ -973,7 +986,9 @@ pub use get_runtime_snapshot_procedure::get_runtime_snapshot;
|
||||
pub use get_story_session_state_procedure::get_story_session_state;
|
||||
pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return;
|
||||
pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot;
|
||||
pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks;
|
||||
pub use import_database_migration_from_file_procedure::import_database_migration_from_file;
|
||||
pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks;
|
||||
pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file;
|
||||
pub use list_asset_history_and_return_procedure::list_asset_history_and_return;
|
||||
pub use list_big_fish_works_procedure::list_big_fish_works;
|
||||
@@ -989,6 +1004,7 @@ pub use publish_big_fish_game_procedure::publish_big_fish_game;
|
||||
pub use publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return;
|
||||
pub use publish_custom_world_world_procedure::publish_custom_world_world;
|
||||
pub use publish_puzzle_work_procedure::publish_puzzle_work;
|
||||
pub use put_database_migration_import_chunk_procedure::put_database_migration_import_chunk;
|
||||
pub use record_big_fish_like_procedure::record_big_fish_like;
|
||||
pub use record_big_fish_play_procedure::record_big_fish_play;
|
||||
pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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::database_migration_procedure_result_type::DatabaseMigrationProcedureResult;
|
||||
use super::database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct PutDatabaseMigrationImportChunkArgs {
|
||||
pub input: DatabaseMigrationImportChunkInput,
|
||||
}
|
||||
|
||||
|
||||
impl __sdk::InModule for PutDatabaseMigrationImportChunkArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `put_database_migration_import_chunk`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait put_database_migration_import_chunk {
|
||||
fn put_database_migration_import_chunk(&self, input: DatabaseMigrationImportChunkInput,
|
||||
) {
|
||||
self.put_database_migration_import_chunk_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn put_database_migration_import_chunk_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunkInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl put_database_migration_import_chunk for super::RemoteProcedures {
|
||||
fn put_database_migration_import_chunk_then(
|
||||
&self,
|
||||
input: DatabaseMigrationImportChunkInput,
|
||||
|
||||
__callback: impl FnOnce(&super::ProcedureEventContext, Result<DatabaseMigrationProcedureResult, __sdk::InternalError>) + Send + 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>(
|
||||
"put_database_migration_import_chunk",
|
||||
PutDatabaseMigrationImportChunkArgs { input, },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option::<String>,
|
||||
pub levels_json: Option::<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ impl SpacetimeClient {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
level_id: input.level_id,
|
||||
levels_json: input.levels_json,
|
||||
candidates_json: input.candidates_json,
|
||||
saved_at_micros: input.saved_at_micros,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
|
||||
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
|
||||
upsert_profile_played_work, PublicWorkLikeRecordInput,
|
||||
ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput,
|
||||
add_profile_observed_play_time, count_recent_public_work_plays, record_public_work_like,
|
||||
record_public_work_play, upsert_profile_played_work,
|
||||
};
|
||||
use crate::*;
|
||||
|
||||
|
||||
@@ -3322,7 +3322,10 @@ fn record_custom_world_profile_like_record(
|
||||
.filter(|row| row.owner_user_id == existing.owner_user_id)
|
||||
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
|
||||
.ok_or_else(|| "custom_world gallery_entry 不存在".to_string())?;
|
||||
return Ok((build_custom_world_profile_snapshot(&existing), gallery_entry));
|
||||
return Ok((
|
||||
build_custom_world_profile_snapshot(&existing),
|
||||
gallery_entry,
|
||||
));
|
||||
}
|
||||
|
||||
// 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
|
||||
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
|
||||
upsert_profile_played_work, PublicWorkLikeRecordInput,
|
||||
ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput,
|
||||
ProfileSaveArchiveUpsertInput,
|
||||
add_profile_observed_play_time, count_recent_public_work_plays, record_public_work_like,
|
||||
record_public_work_play, upsert_profile_played_work, upsert_profile_save_archive,
|
||||
};
|
||||
use module_puzzle::{
|
||||
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK,
|
||||
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
|
||||
PuzzleLeaderboardSubmitInput,
|
||||
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
|
||||
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput,
|
||||
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
|
||||
PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||
PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput,
|
||||
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
|
||||
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
|
||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
||||
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
|
||||
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, 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, resolve_puzzle_grid_size, select_next_profile, selected_puzzle_level,
|
||||
replace_puzzle_level, resolve_puzzle_grid_size, select_next_profiles,
|
||||
selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score,
|
||||
};
|
||||
use serde_json::from_str as json_from_str;
|
||||
use serde_json::json;
|
||||
use serde_json::to_string as json_to_string;
|
||||
use spacetimedb::{ProcedureContext, Table, Timestamp, TxContext};
|
||||
|
||||
@@ -889,6 +892,11 @@ fn save_puzzle_generated_images_tx(
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
|
||||
draft.levels = levels;
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
}
|
||||
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
|
||||
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
|
||||
if candidates.is_empty() {
|
||||
@@ -1539,12 +1547,7 @@ fn start_puzzle_run_tx(
|
||||
current_profile_id.as_str(),
|
||||
current_grid_size,
|
||||
);
|
||||
run.recommended_next_profile_id = select_next_profile(
|
||||
&entry_profile,
|
||||
&run.played_profile_ids,
|
||||
&list_published_puzzle_profiles(ctx)?,
|
||||
)
|
||||
.map(|value| value.profile_id.clone());
|
||||
refresh_next_level_handoff(ctx, &mut run)?;
|
||||
|
||||
record_public_work_play(
|
||||
ctx,
|
||||
@@ -1576,6 +1579,7 @@ fn get_puzzle_run_tx(
|
||||
deserialize_run(&row.snapshot_json)?,
|
||||
micros_to_millis(now_micros),
|
||||
);
|
||||
refresh_next_level_handoff(ctx, &mut run)?;
|
||||
if serialize_json(&run) != row.snapshot_json {
|
||||
replace_puzzle_runtime_run(ctx, &row, &run, now_micros);
|
||||
}
|
||||
@@ -1608,7 +1612,7 @@ fn swap_puzzle_pieces_tx(
|
||||
micros_to_millis(input.swapped_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
refresh_next_profile_recommendation(ctx, &mut next_run)?;
|
||||
refresh_next_level_handoff(ctx, &mut next_run)?;
|
||||
if let Some((profile_id, grid_size)) = next_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
@@ -1640,7 +1644,7 @@ fn drag_puzzle_piece_or_group_tx(
|
||||
micros_to_millis(input.dragged_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
refresh_next_profile_recommendation(ctx, &mut next_run)?;
|
||||
refresh_next_level_handoff(ctx, &mut next_run)?;
|
||||
if let Some((profile_id, grid_size)) = next_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
@@ -1671,21 +1675,28 @@ fn advance_puzzle_next_level_tx(
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
|
||||
return Err("当前关卡尚未通关".to_string());
|
||||
}
|
||||
let current_profile = build_puzzle_work_profile_from_row(
|
||||
&ctx.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(¤t_level.profile_id)
|
||||
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
|
||||
)?;
|
||||
let candidates = list_published_puzzle_profiles(ctx)?;
|
||||
let next_profile = select_next_profile(
|
||||
¤t_profile,
|
||||
¤t_run.played_profile_ids,
|
||||
&candidates,
|
||||
)
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?
|
||||
.clone();
|
||||
let current_profile_row = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(¤t_level.profile_id)
|
||||
.ok_or_else(|| "当前拼图作品不存在".to_string())?;
|
||||
let current_profile = build_puzzle_work_profile_from_row(¤t_profile_row)?;
|
||||
let next_profile = selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||||
.map(|level| profile_for_single_level(¤t_profile, &level))
|
||||
.or_else(|| {
|
||||
let candidates = list_published_puzzle_profiles(ctx).ok()?;
|
||||
select_next_profiles(
|
||||
¤t_profile,
|
||||
¤t_run.played_profile_ids,
|
||||
&candidates,
|
||||
1,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.cloned()
|
||||
})
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
let mut next_run = module_puzzle::advance_next_level_at(
|
||||
¤t_run,
|
||||
&next_profile,
|
||||
@@ -1701,9 +1712,7 @@ fn advance_puzzle_next_level_tx(
|
||||
&next_profile_id,
|
||||
next_grid_size,
|
||||
);
|
||||
next_run.recommended_next_profile_id =
|
||||
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
|
||||
.map(|value| value.profile_id.clone());
|
||||
refresh_next_level_handoff(ctx, &mut next_run)?;
|
||||
|
||||
if let Some(next_profile_row) = ctx
|
||||
.db
|
||||
@@ -1744,8 +1753,9 @@ fn update_puzzle_run_pause_tx(
|
||||
micros_to_millis(input.updated_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
replace_puzzle_runtime_run(ctx, &row, &next_run, input.updated_at_micros);
|
||||
let mut hydrated_run = next_run;
|
||||
refresh_next_level_handoff(ctx, &mut hydrated_run)?;
|
||||
replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.updated_at_micros);
|
||||
if let Some((profile_id, grid_size)) = hydrated_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
@@ -1774,6 +1784,11 @@ fn use_puzzle_runtime_prop_tx(
|
||||
micros_to_millis(input.used_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
"extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at(
|
||||
¤t_run,
|
||||
micros_to_millis(input.used_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
||||
¤t_run,
|
||||
false,
|
||||
@@ -1788,8 +1803,9 @@ fn use_puzzle_runtime_prop_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
_ => return Err("未知拼图道具".to_string()),
|
||||
};
|
||||
replace_puzzle_runtime_run(ctx, &row, &next_run, input.used_at_micros);
|
||||
let mut hydrated_run = next_run;
|
||||
refresh_next_level_handoff(ctx, &mut hydrated_run)?;
|
||||
replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.used_at_micros);
|
||||
if let Some((profile_id, grid_size)) = hydrated_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
@@ -1883,6 +1899,7 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
);
|
||||
}
|
||||
run.leaderboard_entries = leaderboard_entries;
|
||||
refresh_next_level_handoff(ctx, &mut run)?;
|
||||
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
|
||||
Ok(run)
|
||||
}
|
||||
@@ -1891,6 +1908,14 @@ fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str)
|
||||
run.recommended_next_profile_id
|
||||
.as_ref()
|
||||
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|
||||
|| run
|
||||
.next_level_profile_id
|
||||
.as_ref()
|
||||
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|
||||
|| run
|
||||
.recommended_next_works
|
||||
.iter()
|
||||
.any(|candidate| candidate.profile_id == profile_id)
|
||||
|| run
|
||||
.played_profile_ids
|
||||
.iter()
|
||||
@@ -2328,6 +2353,7 @@ fn insert_puzzle_runtime_run(
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
});
|
||||
upsert_puzzle_profile_save_archive(ctx, run, owner_user_id, created_at_micros)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2356,6 +2382,75 @@ fn replace_puzzle_runtime_run(
|
||||
created_at: current.created_at,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
});
|
||||
if let Err(error) =
|
||||
upsert_puzzle_profile_save_archive(ctx, run, ¤t.owner_user_id, updated_at_micros)
|
||||
{
|
||||
log::warn!("拼图存档投影同步失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_puzzle_profile_save_archive(
|
||||
ctx: &TxContext,
|
||||
run: &PuzzleRunSnapshot,
|
||||
user_id: &str,
|
||||
saved_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(current_level) = run.current_level.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let world_key = format!("puzzle:{}", run.entry_profile_id);
|
||||
|
||||
// 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。
|
||||
let game_state_json = json_to_string(&json!({
|
||||
"runtimeKind": "puzzle",
|
||||
"runId": run.run_id,
|
||||
"entryProfileId": run.entry_profile_id,
|
||||
"currentProfileId": current_level.profile_id,
|
||||
"currentLevelIndex": current_level.level_index,
|
||||
"currentLevelId": current_level.level_id,
|
||||
"status": current_level.status.as_str(),
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
upsert_profile_save_archive(
|
||||
ctx,
|
||||
ProfileSaveArchiveUpsertInput {
|
||||
user_id: user_id.to_string(),
|
||||
world_key,
|
||||
owner_user_id: resolve_puzzle_current_owner_user_id(ctx, ¤t_level.profile_id),
|
||||
profile_id: Some(run.entry_profile_id.clone()),
|
||||
world_type: Some("PUZZLE".to_string()),
|
||||
world_name: current_level.level_name.clone(),
|
||||
subtitle: format!("第 {} 关", current_level.level_index),
|
||||
summary_text: puzzle_archive_summary_text(current_level.status),
|
||||
cover_image_src: current_level.cover_image_src.clone(),
|
||||
bottom_tab: "puzzle".to_string(),
|
||||
game_state_json,
|
||||
current_story_json: None,
|
||||
saved_at_micros,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option<String> {
|
||||
ctx.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.map(|row| row.owner_user_id)
|
||||
}
|
||||
|
||||
fn puzzle_archive_summary_text(status: PuzzleRuntimeLevelStatus) -> String {
|
||||
match status {
|
||||
PuzzleRuntimeLevelStatus::Cleared => "关卡已完成",
|
||||
PuzzleRuntimeLevelStatus::Failed => "关卡失败",
|
||||
PuzzleRuntimeLevelStatus::Playing => "拼图进行中",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn increment_puzzle_profile_play_count(
|
||||
@@ -2439,14 +2534,33 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfi
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_next_profile_recommendation(
|
||||
ctx: &TxContext,
|
||||
run: &mut PuzzleRunSnapshot,
|
||||
) -> Result<(), String> {
|
||||
fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) {
|
||||
run.recommended_next_profile_id = None;
|
||||
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string();
|
||||
run.next_level_profile_id = None;
|
||||
run.next_level_id = None;
|
||||
run.recommended_next_works = Vec::new();
|
||||
}
|
||||
|
||||
fn build_recommended_next_work(
|
||||
current_profile: &PuzzleWorkProfile,
|
||||
candidate: &PuzzleWorkProfile,
|
||||
) -> PuzzleRecommendedNextWork {
|
||||
PuzzleRecommendedNextWork {
|
||||
profile_id: candidate.profile_id.clone(),
|
||||
level_name: candidate.level_name.clone(),
|
||||
author_display_name: candidate.author_display_name.clone(),
|
||||
theme_tags: candidate.theme_tags.clone(),
|
||||
cover_image_src: candidate.cover_image_src.clone(),
|
||||
similarity_score: tag_similarity_score(¤t_profile.theme_tags, &candidate.theme_tags),
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> {
|
||||
let current_level = match run.current_level.as_ref() {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
run.recommended_next_profile_id = None;
|
||||
reset_next_level_handoff(run);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -2457,12 +2571,41 @@ fn refresh_next_profile_recommendation(
|
||||
.find(¤t_level.profile_id)
|
||||
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
|
||||
)?;
|
||||
run.recommended_next_profile_id = select_next_profile(
|
||||
¤t_profile,
|
||||
&run.played_profile_ids,
|
||||
&list_published_puzzle_profiles(ctx)?,
|
||||
)
|
||||
.map(|value| value.profile_id.clone());
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
|
||||
reset_next_level_handoff(run);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(next_level) =
|
||||
selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||||
{
|
||||
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string();
|
||||
run.next_level_profile_id = Some(current_profile.profile_id.clone());
|
||||
run.next_level_id = Some(next_level.level_id);
|
||||
run.recommended_next_profile_id = Some(current_profile.profile_id.clone());
|
||||
run.recommended_next_works = Vec::new();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let candidates = list_published_puzzle_profiles(ctx)?;
|
||||
let recommended_next_works =
|
||||
select_next_profiles(¤t_profile, &run.played_profile_ids, &candidates, 3)
|
||||
.into_iter()
|
||||
.map(|candidate| build_recommended_next_work(¤t_profile, candidate))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if recommended_next_works.is_empty() {
|
||||
reset_next_level_handoff(run);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string();
|
||||
run.next_level_profile_id = recommended_next_works
|
||||
.first()
|
||||
.map(|candidate| candidate.profile_id.clone());
|
||||
run.next_level_id = None;
|
||||
run.recommended_next_profile_id = run.next_level_profile_id.clone();
|
||||
run.recommended_next_works = recommended_next_works;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2640,6 +2783,10 @@ mod tests {
|
||||
previous_level_tags: vec!["蒸汽城市".to_string()],
|
||||
current_level: None,
|
||||
recommended_next_profile_id: None,
|
||||
next_level_mode: PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(),
|
||||
next_level_profile_id: None,
|
||||
next_level_id: None,
|
||||
recommended_next_works: Vec::new(),
|
||||
leaderboard_entries: Vec::new(),
|
||||
};
|
||||
let serialized = serialize_json(&snapshot);
|
||||
|
||||
@@ -181,6 +181,22 @@ pub(crate) struct PublicWorkLikeRecordInput {
|
||||
pub(crate) liked_at_micros: i64,
|
||||
}
|
||||
|
||||
pub(crate) struct ProfileSaveArchiveUpsertInput {
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) world_key: String,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) profile_id: Option<String>,
|
||||
pub(crate) world_type: Option<String>,
|
||||
pub(crate) world_name: String,
|
||||
pub(crate) subtitle: String,
|
||||
pub(crate) summary_text: String,
|
||||
pub(crate) cover_image_src: Option<String>,
|
||||
pub(crate) bottom_tab: String,
|
||||
pub(crate) game_state_json: String,
|
||||
pub(crate) current_story_json: Option<String>,
|
||||
pub(crate) saved_at_micros: i64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_membership)]
|
||||
pub struct ProfileMembership {
|
||||
#[primary_key]
|
||||
@@ -759,6 +775,53 @@ pub(crate) fn add_profile_observed_play_time(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_profile_save_archive(
|
||||
ctx: &ReducerContext,
|
||||
input: ProfileSaveArchiveUpsertInput,
|
||||
) -> Result<(), String> {
|
||||
let user_id = input.user_id.trim();
|
||||
let world_key = input.world_key.trim();
|
||||
if user_id.is_empty() || world_key.is_empty() {
|
||||
return Err("profile_save_archive 参数不能为空".to_string());
|
||||
}
|
||||
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
||||
let archive_id = format!("{user_id}:{world_key}");
|
||||
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
|
||||
let created_at = existing
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or(saved_at);
|
||||
|
||||
if let Some(existing) = existing {
|
||||
ctx.db
|
||||
.profile_save_archive()
|
||||
.archive_id()
|
||||
.delete(&existing.archive_id);
|
||||
}
|
||||
|
||||
ctx.db.profile_save_archive().insert(ProfileSaveArchive {
|
||||
archive_id,
|
||||
user_id: user_id.to_string(),
|
||||
world_key: world_key.to_string(),
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
world_type: input.world_type,
|
||||
world_name: input.world_name,
|
||||
subtitle: input.subtitle,
|
||||
summary_text: input.summary_text,
|
||||
cover_image_src: input.cover_image_src,
|
||||
saved_at,
|
||||
bottom_tab: input.bottom_tab,
|
||||
game_state_json: input.game_state_json,
|
||||
current_story_json: input.current_story_json,
|
||||
created_at,
|
||||
updated_at: saved_at,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn record_public_work_play(
|
||||
ctx: &ReducerContext,
|
||||
input: PublicWorkPlayRecordInput,
|
||||
|
||||
Reference in New Issue
Block a user