feat: add puzzle clear template runtime

This commit is contained in:
2026-06-03 22:11:46 +08:00
parent 6e74cf5add
commit 1b5e098225
148 changed files with 19588 additions and 241 deletions

View File

@@ -26,6 +26,7 @@ module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-puzzle-clear = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }

View File

@@ -64,6 +64,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::jump_hop::router(state.clone()))
.merge(modules::wooden_fish::router(state.clone()))
.merge(modules::public_work::router(state.clone()))
.merge(modules::puzzle_clear::router(state.clone()))
.merge(modules::puzzle::router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route(

View File

@@ -94,13 +94,11 @@ pub async fn generate_character_visual(
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
let settings = require_openai_image_settings(&state)?
.with_external_api_audit_context(
&request_context,
Some(owner_user_id.clone()),
Some(character_id.clone()),
)
;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(owner_user_id.clone()),
Some(character_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
state
@@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&model,
&prompt,
)?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
Some(owner_user_id.to_string()),
Some(character_id.clone()),
);
let settings = require_openai_image_settings(state)?
.with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone()));
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()

View File

@@ -72,6 +72,12 @@ pub async fn require_creation_entry_route_enabled(
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
let normalized = path.trim_end_matches('/');
if normalized.starts_with("/api/runtime/puzzle-clear") {
return Some("puzzle-clear");
}
if normalized.starts_with("/api/creation/puzzle-clear") {
return Some("puzzle-clear");
}
if normalized.starts_with("/api/runtime/puzzle") {
return Some("puzzle");
}
@@ -173,6 +179,14 @@ mod tests {
resolve_creation_entry_route_id("/api/runtime/puzzle/works"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"),
Some("puzzle-clear"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
Some("puzzle-clear"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"),
Some("match3d"),

View File

@@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image(
"scene_image",
asset_id.as_str(),
async {
let settings = require_openai_image_settings(&state)?
.with_external_api_audit_context(
&request_context,
Some(owner_user_id.to_string()),
normalized.profile_id.clone(),
);
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(owner_user_id.to_string()),
normalized.profile_id.clone(),
);
let http_client = build_openai_image_http_client(&settings)?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {

View File

@@ -1052,6 +1052,7 @@ mod tests {
external_api_audit_state: None,
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
});
assert_eq!(

View File

@@ -414,12 +414,11 @@ async fn maybe_generate_jump_hop_assets(
let settings = require_openai_image_settings(state)
.map(|settings| {
settings
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.clone()),
)
settings.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.clone()),
)
})
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)

View File

@@ -64,6 +64,7 @@ mod prompt;
mod public_work;
mod puzzle;
mod puzzle_agent_turn;
mod puzzle_clear;
mod puzzle_gallery_cache;
mod refresh_session;
mod registration_reward;

View File

@@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene(
config: &Match3DConfigJson,
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let prompt = build_match3d_item_spritesheet_prompt();
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;

View File

@@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset(
reference_image_srcs: Vec<String>,
) -> Result<Match3DAssetUpload, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
@@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle(
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
@@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image(
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_image()?;
let container_prompt = build_match3d_container_generation_prompt(config, prompt);

View File

@@ -13,6 +13,7 @@ pub mod platform;
pub mod profile;
pub mod public_work;
pub mod puzzle;
pub mod puzzle_clear;
pub mod square_hole;
pub mod story;
pub mod wooden_fish;

View File

@@ -0,0 +1,116 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
puzzle_clear::{
advance_puzzle_clear_next_level, create_puzzle_clear_session, execute_puzzle_clear_action,
get_puzzle_clear_gallery_detail, get_puzzle_clear_run, get_puzzle_clear_runtime_work,
get_puzzle_clear_session, get_puzzle_clear_work, list_puzzle_clear_gallery,
list_puzzle_clear_works, mark_puzzle_clear_level_time_up, publish_puzzle_clear_work,
retry_puzzle_clear_level, start_puzzle_clear_run, swap_puzzle_clear_cards,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/puzzle-clear/sessions",
post(create_puzzle_clear_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/sessions/{session_id}",
get(get_puzzle_clear_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/sessions/{session_id}/actions",
post(execute_puzzle_clear_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/works",
get(list_puzzle_clear_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/works/{profile_id}",
get(get_puzzle_clear_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/works/{profile_id}/publish",
post(publish_puzzle_clear_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/works/{profile_id}",
get(get_puzzle_clear_runtime_work),
)
.route(
"/api/runtime/puzzle-clear/runs",
post(start_puzzle_clear_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}",
get(get_puzzle_clear_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/swap",
post(swap_puzzle_clear_cards).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/retry-level",
post(retry_puzzle_clear_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/next-level",
post(advance_puzzle_clear_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/time-up",
post(mark_puzzle_clear_level_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/gallery",
get(list_puzzle_clear_gallery),
)
.route(
"/api/runtime/puzzle-clear/gallery/{public_work_code}",
get(get_puzzle_clear_gallery_detail),
)
}

View File

@@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
level_name: &str,
puzzle_image: &PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
let settings = require_puzzle_vector_engine_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(session_id.to_string()),
);
let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(session_id.to_string()),
);
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let scene_generated = create_puzzle_vector_engine_image_generation(

View File

@@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings {
) -> Self {
self.external_api_audit_user_id = user_id;
self.external_api_audit_profile_id = profile_id;
self.external_api_audit_request_id =
Some(request_context.request_id().to_string());
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
self
}
}
pub(crate) struct ParsedPuzzleImageDataUrl {

File diff suppressed because it is too large Load Diff

View File

@@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url(
size: &str,
failure_context: &str,
) -> Result<String, AppError> {
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,

View File

@@ -1292,6 +1292,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::AppConfig;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
#[test]
@@ -1461,8 +1462,9 @@ mod tests {
assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音"));
}
#[test]
fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
#[tokio::test]
async fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let payload = WoodenFishWorkspaceCreateRequest {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
work_title: "今日敲木鱼".to_string(),
@@ -1475,7 +1477,9 @@ mod tests {
floating_words: vec![],
};
let draft = build_wooden_fish_draft(&payload);
let draft = build_wooden_fish_draft(&payload, &state)
.await
.expect("draft should build");
assert!(draft.hit_sound_prompt.is_none());
let asset = draft