This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

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

View 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, "关卡画面描述");
}
}

View File

@@ -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 元素"));
}

View File

@@ -1,2 +1,3 @@
pub(crate) mod agent_chat;
pub(crate) mod draft;
pub(crate) mod image;

View File

@@ -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_breakoffsuggestions 与 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 必须为 trueterminationReason 必须为 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"));
}
}

View File

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

View File

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

View File

@@ -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(&current_profile.theme_tags, &left.theme_tags)
tag_similarity_score(&current_profile.theme_tags, &right.theme_tags)
.partial_cmp(&tag_similarity_score(
&current_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!["蒸汽城市", "雨夜"]);

View File

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

View File

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

View File

@@ -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)]

View File

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

View File

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

View File

@@ -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,
);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)]

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

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

View File

@@ -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,
);
}
}

View File

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

View File

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

View File

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

View File

@@ -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,
));
}
// 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。

View File

@@ -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(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
let candidates = list_published_puzzle_profiles(ctx)?;
let next_profile = select_next_profile(
&current_profile,
&current_run.played_profile_ids,
&candidates,
)
.ok_or_else(|| "没有可用的下一关候选".to_string())?
.clone();
let current_profile_row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?;
let current_profile = build_puzzle_work_profile_from_row(&current_profile_row)?;
let next_profile = selected_profile_level_after_runtime_level(&current_profile, current_level)
.map(|level| profile_for_single_level(&current_profile, &level))
.or_else(|| {
let candidates = list_published_puzzle_profiles(ctx).ok()?;
select_next_profiles(
&current_profile,
&current_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(
&current_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(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_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, &current.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, &current_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(&current_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(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
run.recommended_next_profile_id = select_next_profile(
&current_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(&current_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(&current_profile, &run.played_profile_ids, &candidates, 3)
.into_iter()
.map(|candidate| build_recommended_next_work(&current_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);

View File

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