This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -80,6 +80,7 @@ use crate::{
password_entry::password_entry,
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
profile_identity::update_profile_identity,
puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
@@ -87,6 +88,7 @@ use crate::{
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
update_puzzle_run_pause, use_puzzle_runtime_prop,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
@@ -247,6 +249,12 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/me",
axum::routing::patch(update_profile_identity).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/auth/refresh",
post(refresh_session).route_layer(middleware::from_fn_with_state(
@@ -783,6 +791,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/pause",
post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/props",
post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(

View File

@@ -29,7 +29,7 @@ where
}
}
/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
/// 资产操作统一预扣陶泥币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
async fn consume_asset_operation_points(
state: &AppState,
owner_user_id: &str,
@@ -79,7 +79,7 @@ async fn refund_asset_operation_points(
asset_kind,
asset_id,
error = %error,
"资产操作失败后的叙世币退款失败"
"资产操作失败后的陶泥币退款失败"
);
}
}
@@ -87,7 +87,7 @@ async fn refund_asset_operation_points(
pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => {
SpacetimeClientError::Procedure(message) if message.contains("陶泥币余额不足") => {
StatusCode::CONFLICT
}
_ => StatusCode::BAD_GATEWAY,

View File

@@ -7,6 +7,7 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
@@ -19,5 +20,6 @@ pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPaylo
id: user.id,
public_user_code: user.public_user_code,
display_name: user.display_name,
avatar_url: user.avatar_url,
}
}

View File

@@ -20,7 +20,7 @@ pub async fn get_public_user_by_code(
.get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户")
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应陶泥号用户")
})?;
Ok(json_success_body(
@@ -60,12 +60,15 @@ pub async fn get_public_user_by_id(
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确")
AppError::from_status(StatusCode::BAD_REQUEST).with_message("陶泥号格式不正确")
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidPhoneNumber
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidDisplayName
| module_auth::PasswordEntryError::InvalidAvatarDataUrl
| module_auth::PasswordEntryError::EmptyProfileUpdate
| module_auth::PasswordEntryError::InvalidCredentials
| module_auth::PasswordEntryError::UserNotFound => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())

View File

@@ -936,7 +936,9 @@ fn map_big_fish_work_summary_response(
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
published_at: item.published_at_micros.map(current_timestamp_micros_to_string),
published_at: item
.published_at_micros
.map(current_timestamp_micros_to_string),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
@@ -945,6 +947,7 @@ fn map_big_fish_work_summary_response(
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
}
}

View File

@@ -1,4 +1,4 @@
use std::{env, fs, net::SocketAddr, path::PathBuf};
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -74,6 +74,7 @@ pub struct AppConfig {
pub spacetime_database: String,
pub spacetime_token: Option<String>,
pub spacetime_pool_size: u32,
pub spacetime_procedure_timeout: Duration,
pub llm_provider: LlmProvider,
pub llm_base_url: String,
pub llm_api_key: Option<String>,
@@ -165,6 +166,7 @@ impl Default for AppConfig {
spacetime_database: "genarrative-dev".to_string(),
spacetime_token: None,
spacetime_pool_size: 4,
spacetime_procedure_timeout: Duration::from_secs(30),
llm_provider: LlmProvider::Ark,
llm_base_url: DEFAULT_ARK_BASE_URL.to_string(),
llm_api_key: None,
@@ -436,6 +438,12 @@ impl AppConfig {
{
config.spacetime_pool_size = spacetime_pool_size;
}
if let Some(spacetime_procedure_timeout_seconds) =
read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"])
{
config.spacetime_procedure_timeout =
Duration::from_secs(spacetime_procedure_timeout_seconds);
}
if let Some(llm_provider) =
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
@@ -840,6 +848,26 @@ mod tests {
}
}
#[test]
fn from_env_reads_spacetime_procedure_timeout() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS");
std::env::set_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS", "45");
}
let config = AppConfig::from_env();
assert_eq!(config.spacetime_procedure_timeout.as_secs(), 45);
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS");
}
}
#[test]
fn from_env_reads_rpg_llm_web_search_switch() {
let _guard = ENV_LOCK

View File

@@ -414,9 +414,10 @@ pub async fn get_custom_world_library(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let author_display_name = resolve_author_display_name(&state, &authenticated);
let entries = state
.spacetime_client()
.list_custom_world_profiles(owner_user_id)
.list_custom_world_works(owner_user_id.clone())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
@@ -427,7 +428,13 @@ pub async fn get_custom_world_library(
CustomWorldLibraryResponse {
entries: entries
.into_iter()
.map(map_custom_world_library_entry_response)
.filter_map(|item| {
map_custom_world_library_entry_response_from_work_summary(
item,
&owner_user_id,
&author_display_name,
)
})
.collect(),
},
))
@@ -2712,9 +2719,89 @@ fn map_custom_world_library_entry_response(
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: 0,
}
}
fn map_custom_world_library_entry_response_from_work_summary(
item: CustomWorldWorkSummaryRecord,
owner_user_id: &str,
author_display_name: &str,
) -> Option<CustomWorldLibraryEntryResponse> {
let profile_id = item.profile_id.as_ref()?.clone();
let profile = build_custom_world_library_list_profile_payload(&item, &profile_id);
Some(CustomWorldLibraryEntryResponse {
owner_user_id: owner_user_id.to_string(),
public_work_code: (item.status == "published")
.then(|| build_public_work_code_from_profile_id(&profile_id)),
profile_id,
author_public_user_code: None,
profile,
visibility: item.status,
published_at: item.published_at,
updated_at: item.updated_at,
author_display_name: author_display_name.to_string(),
world_name: item.title,
subtitle: item.subtitle,
summary_text: item.summary,
cover_image_src: item.cover_image_src,
theme_mode: "mythic".to_string(),
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
})
}
fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
fn build_custom_world_library_list_profile_payload(
item: &CustomWorldWorkSummaryRecord,
profile_id: &str,
) -> Value {
json!({
"id": profile_id,
"name": item.title,
"subtitle": item.subtitle,
"summary": item.summary,
"tone": "",
"playerGoal": "",
"settingText": "",
"themeMode": "mythic",
"templateWorldType": "WUXIA",
"compatibilityTemplateWorldType": Value::Null,
"cover": item.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": [],
"coreConflicts": [],
"playableNpcs": [],
"storyNpcs": [],
"items": [],
"camp": Value::Null,
"landmarks": [],
"ownedSettingLayers": Value::Null,
})
}
fn map_custom_world_gallery_card_response(
entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
@@ -2737,6 +2824,7 @@ fn map_custom_world_gallery_card_response(
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
@@ -3308,7 +3396,7 @@ fn resolve_author_public_user_code(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library",
"message": format!("作者叙世号读取失败:{error}"),
"message": format!("作者陶泥号读取失败:{error}"),
})),
)
})?
@@ -3319,7 +3407,7 @@ fn resolve_author_public_user_code(
request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library",
"message": "当前登录用户缺少叙世",
"message": "当前登录用户缺少陶泥",
})),
)
})

View File

@@ -39,7 +39,7 @@ pub async fn generate_custom_world_foundation_draft(
emit_foundation_draft_progress(
&mut on_progress,
"整理世界骨架",
"正在根据创作者锚点生成第一版世界框架。",
"正在根据陶泥主锚点生成第一版世界框架。",
12,
);
let mut framework = request_foundation_json_stage(

View File

@@ -42,6 +42,7 @@ mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod profile_identity;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;

View File

@@ -80,10 +80,15 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
"field": "password",
})),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_message("陶泥号格式不正确")
.with_details(json!({
"field": "phone",
})),
PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
}

View File

@@ -103,6 +103,11 @@ fn map_password_management_error(error: PasswordEntryError) -> AppError {
PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间"),
PasswordEntryError::InvalidCredentials => {

View File

@@ -0,0 +1,105 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::GenericImageView;
use module_auth::{PasswordEntryError, UpdateProfileInput};
use shared_contracts::auth::{ProfileUpdateRequest, ProfileUpdateResponse};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext,
state::AppState,
};
const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024;
const AVATAR_SIZE_PX: u32 = 256;
pub async fn update_profile_identity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ProfileUpdateRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() {
validate_avatar_data_url(avatar_data_url)?;
}
let result = state
.password_entry_service()
.update_profile(UpdateProfileInput {
user_id: authenticated.claims().user_id().to_string(),
display_name: payload.display_name,
avatar_url: payload.avatar_data_url,
})
.map_err(map_profile_update_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
Ok(json_success_body(
Some(&request_context),
ProfileUpdateResponse {
user: map_auth_user_payload(result.user),
},
))
}
fn validate_avatar_data_url(value: &str) -> Result<(), AppError> {
let Some((header, payload)) = value.trim().split_once(',') else {
return Err(invalid_avatar_error("头像图片格式不正确"));
};
if !matches!(
header,
"data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64"
) {
return Err(invalid_avatar_error("头像仅支持 jpg、png、webp"));
}
let bytes = BASE64_STANDARD
.decode(payload)
.map_err(|_| invalid_avatar_error("头像图片格式不正确"))?;
if bytes.len() > MAX_AVATAR_BYTES {
return Err(invalid_avatar_error("头像图片不能超过 5MB"));
}
let image =
image::load_from_memory(&bytes).map_err(|_| invalid_avatar_error("头像图片格式不正确"))?;
let (width, height) = image.dimensions();
if width != AVATAR_SIZE_PX || height != AVATAR_SIZE_PX {
return Err(invalid_avatar_error("头像裁剪尺寸需要为 256x256"));
}
Ok(())
}
fn invalid_avatar_error(message: &'static str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
fn map_profile_update_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("当前登录态已失效,请重新登录"),
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
}
}

View File

@@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。

View File

@@ -5,14 +5,14 @@
pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。
/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
format!(
concat!(
"请生成一张适合正方形拼图关卡的高清插画。",
"请生成一张适合 9:16 竖屏拼图关卡的高清插画。",
"关卡名:{level_name}。",
"画面主体:{prompt}。",
"画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,",
"画面要求:9:16 竖屏画布,适配 3x3 或 4x4 拼图切块,",
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
"避免文字、水印、边框和 UI 元素。"
),
@@ -31,7 +31,7 @@ mod tests {
assert!(prompt.contains("雨夜神庙"));
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
assert!(prompt.contains("正方形拼图关卡"));
assert!(prompt.contains("9:16 竖屏拼图关卡"));
assert!(prompt.contains("3x3 或 4x4"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
}

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -40,7 +40,7 @@ use shared_contracts::{
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest,
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -57,9 +57,10 @@ use spacetime_client::{
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -85,6 +86,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "720*1280";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -103,7 +105,7 @@ pub async fn create_puzzle_agent_session(
)
})?;
let seed_text = payload.seed_text.unwrap_or_default().trim().to_string();
let seed_text = build_puzzle_form_seed_text(&payload);
let session = state
.spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
@@ -455,6 +457,8 @@ pub async fn execute_puzzle_agent_action(
&state,
session_id.clone(),
owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
now,
)
.await
@@ -1142,6 +1146,120 @@ pub async fn advance_puzzle_next_level(
))
}
pub async fn update_puzzle_run_pause(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.update_puzzle_run_pause(PuzzleRunPauseRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
paused: payload.paused,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
pub async fn use_puzzle_runtime_prop(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.prop_kind,
"propKind",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let prop_kind = payload.prop_kind.trim().to_string();
let billing_asset_kind = match prop_kind.as_str() {
"hint" => "puzzle_prop_hint",
"reference" => "puzzle_prop_preview",
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
_ => {
return Err(puzzle_bad_request(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
"unknown puzzle prop kind",
));
}
};
let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
let reducer_owner_user_id = owner_user_id.clone();
let run = execute_billable_asset_operation(
&state,
&owner_user_id,
billing_asset_kind,
billing_asset_id.as_str(),
async {
state
.spacetime_client()
.use_puzzle_runtime_prop(PuzzleRunPropRecordInput {
run_id,
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
pub async fn advance_local_puzzle_next_level(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1399,6 +1517,7 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
publish_ready: item.publish_ready,
}
}
@@ -1465,6 +1584,13 @@ fn map_puzzle_level_request_record(
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms: level.time_limit_ms,
remaining_ms: level.remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
@@ -1524,6 +1650,18 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle
fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size);
let time_limit_ms = if level.time_limit_ms == 0 {
timer_defaults.time_limit_ms
} else {
level.time_limit_ms
};
let remaining_ms =
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
time_limit_ms
} else {
level.remaining_ms.min(time_limit_ms)
};
PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id,
level_index: level.level_index,
@@ -1538,6 +1676,13 @@ fn map_puzzle_runtime_level_response(
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms,
remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
@@ -1546,6 +1691,17 @@ fn map_puzzle_runtime_level_response(
}
}
struct PuzzleRuntimeTimerResponseDefaults {
time_limit_ms: u64,
}
fn build_puzzle_runtime_timer_response_defaults(
grid_size: u32,
) -> PuzzleRuntimeTimerResponseDefaults {
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
}
fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
@@ -1612,10 +1768,28 @@ fn resolve_author_display_name(
fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点".to_string();
return "拼图创作信息已准备好".to_string();
}
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡".to_string()
"拼图创作信息已准备好".to_string()
}
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
let title = payload.seed_text.as_deref().unwrap_or_default().trim();
let picture_description = payload
.picture_description
.as_deref()
.unwrap_or_default()
.trim();
if title.is_empty() && picture_description.is_empty() {
return String::new();
}
if title.is_empty() || picture_description.is_empty() {
return format!("{title}{picture_description}");
}
format!("拼图标题:{title}\n画面描述:{picture_description}")
}
fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
@@ -1632,6 +1806,8 @@ async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
session_id: String,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let compiled_session = state
@@ -1648,8 +1824,11 @@ async fn compile_puzzle_draft_with_initial_cover(
owner_user_id.as_str(),
&compiled_session.session_id,
&draft.level_name,
&draft.summary,
None,
prompt_text
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.summary.as_str()),
reference_image_src,
1,
draft.candidates.len(),
)
@@ -1815,6 +1994,7 @@ async fn generate_puzzle_image_candidates(
None => None,
};
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 9:16 竖屏生成,运行时棋盘也按同一比例切块承载。
let generated = match reference_image.as_deref() {
Some(reference_image) => {
create_puzzle_image_to_image_generation(
@@ -1822,7 +2002,7 @@ async fn generate_puzzle_image_candidates(
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
PUZZLE_GENERATED_IMAGE_SIZE,
count,
reference_image,
)
@@ -1834,7 +2014,7 @@ async fn generate_puzzle_image_candidates(
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
PUZZLE_GENERATED_IMAGE_SIZE,
count,
)
.await
@@ -2079,6 +2259,7 @@ fn build_next_run_from_parts(
) -> 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();
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
@@ -2106,6 +2287,13 @@ fn build_next_run_from_parts(
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms,
remaining_ms: time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
@@ -2221,6 +2409,11 @@ mod tests {
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
#[test]
fn puzzle_generated_image_size_is_portrait_9_16() {
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "720*1280");
}
}
struct PuzzleDashScopeSettings {

View File

@@ -60,7 +60,7 @@ struct PuzzleAgentModelOutput {
next_anchor_pack: PuzzleAnchorPack,
}
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。

View File

@@ -168,7 +168,10 @@ fn resolve_npc_battle_formation(
if !visible_formation.is_empty() {
return visible_formation
.into_iter()
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
.enumerate()
.map(|(index, monster)| {
normalize_npc_battle_monster(monster, encounter, battle_mode, index)
})
.collect();
}
@@ -185,7 +188,12 @@ fn resolve_npc_battle_formation(
.unwrap_or_default()
}
fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value {
fn normalize_npc_battle_monster(
mut monster: Value,
fallback_encounter: Option<&Value>,
battle_mode: &str,
index: usize,
) -> Value {
let Some(monster_object) = monster.as_object_mut() else {
return monster;
};
@@ -211,6 +219,26 @@ fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value
monster_object
.entry("hp".to_string())
.or_insert_with(|| json!(max_hp));
if !monster_object
.get("encounter")
.is_some_and(|value| value.is_object())
&& let Some(fallback_encounter) = fallback_encounter
{
// 中文注释:进入 NPC 战斗时画布已经改由 sceneHostileNpcs 渲染敌方;
// 旧快照里的敌方条目可能只有数值没有形象上下文,必须把当前 NPC encounter 补进去。
let mut battle_encounter = fallback_encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
if !entry.contains_key("xMeters") {
let x_meters = monster_object
.get("xMeters")
.and_then(Value::as_f64)
.unwrap_or(3.2 + index as f64 * 1.08);
entry.insert("xMeters".to_string(), json!(x_meters));
}
}
monster_object.insert("encounter".to_string(), battle_encounter);
}
monster
}

View File

@@ -2502,6 +2502,58 @@ fn runtime_story_npc_fight_resolves_battle_snapshot_without_frontend_bridge() {
);
}
#[test]
fn runtime_story_npc_fight_does_not_accept_pending_quest_and_keeps_target_renderable() {
let request = RuntimeStoryActionRequest {
session_id: "runtime-main".to_string(),
client_version: Some(0),
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: "npc_fight".to_string(),
target_id: None,
payload: Some(json!({ "optionText": "直接开战" })),
},
snapshot: None,
};
let mut game_state = build_runtime_story_boundary_game_state_fixture();
ensure_json_object(&mut game_state).insert(
"sceneHostileNpcs".to_string(),
json!([{
"id": "npc_merchant_01",
"name": "沈七",
"hp": 30,
"maxHp": 30,
"xMeters": 3.2
}]),
);
let current_story = build_runtime_story_pending_quest_offer_fixture(
build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"),
);
let resolution = resolve_runtime_story_choice_action(
&mut game_state,
Some(&current_story),
&request,
"npc_fight",
)
.expect("npc fight should resolve");
assert!(resolution.result_text.contains("战斗节奏"));
assert!(read_array_field(&game_state, "quests").is_empty());
assert_eq!(
read_field(&game_state, "runtimeStats")
.and_then(|stats| read_i32_field(stats, "questsAccepted")),
Some(0)
);
let formation = read_array_field(&game_state, "sceneHostileNpcs");
assert_eq!(formation.len(), 1);
assert_eq!(
read_object_field(formation[0], "encounter")
.and_then(|encounter| read_optional_string_field(encounter, "id")),
Some("npc_merchant_01".to_string())
);
}
#[test]
fn runtime_story_npc_spar_resolves_lightweight_battle_snapshot() {
let request = RuntimeStoryActionRequest {

View File

@@ -164,6 +164,7 @@ impl AppState {
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
});
let llm_client = build_llm_client(&config)?;
@@ -242,6 +243,7 @@ impl AppState {
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
});
match spacetime_client
.export_auth_store_snapshot_from_tables()