fix: 完善拼消消模板运行规则

This commit is contained in:
2026-06-11 00:50:18 +08:00
parent c98c3de96d
commit 21ac5642e8
19 changed files with 1952 additions and 317 deletions

View File

@@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::{
collections::BTreeMap,
env, fs,
path::{Path as FsPath, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
@@ -38,8 +40,8 @@ use crate::{
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body,
create_openai_image_generation, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
@@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet {
image: DownloadedOpenAiImage,
}
#[derive(Clone, Debug)]
struct PuzzleClearImageDebugRun {
root: PathBuf,
run_id: String,
}
impl PuzzleClearImageDebugRun {
fn record_spec(
&self,
sheet_specs: &[PuzzleClearAtlasSheetSpec],
groups: &[PuzzleClearPatternGroup],
) {
let sheets = sheet_specs
.iter()
.map(|spec| {
json!({
"sheetId": spec.sheet_id,
"layout": spec.layout.iter().map(|row| row.to_vec()).collect::<Vec<_>>(),
"layoutPrompt": spec.layout_prompt,
})
})
.collect::<Vec<_>>();
let groups = groups
.iter()
.map(|group| {
json!({
"groupId": group.group_id.as_str(),
"shape": group.shape.as_str(),
"width": group.width,
"height": group.height,
"atlasX": group.atlas_x,
"atlasY": group.atlas_y,
"atlasWidth": group.atlas_width,
"atlasHeight": group.atlas_height,
})
})
.collect::<Vec<_>>();
self.write_json(
"specs/puzzle-clear-debug-spec.json",
&json!({
"cellSize": PUZZLE_CLEAR_ATLAS_CELL_SIZE,
"sheetColumns": PUZZLE_CLEAR_SHEET_COLUMNS,
"sheetRows": PUZZLE_CLEAR_SHEET_ROWS,
"finalAtlasColumns": PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
"finalAtlasRows": PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
"sheets": sheets,
"groups": groups,
}),
"记录拼消消调试规格失败",
);
}
fn record_board_background_request(&self, prompt: &str) {
self.write_text(
"prompts/board-background.txt",
prompt,
"记录拼消消底图 prompt 失败",
);
self.write_json(
"requests/board-background.json",
&json!({
"endpoint": "/v1/images/generations",
"body": build_openai_image_request_body(
prompt,
Some("文字、水印、按钮、教程浮层、明显网格"),
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
1,
&[],
),
}),
"记录拼消消底图 request 失败",
);
}
fn record_board_background_image(
&self,
task_id: &str,
actual_prompt: Option<&str>,
image: &DownloadedOpenAiImage,
) {
let extension = puzzle_clear_debug_image_extension(image);
self.write_bytes(
format!("background/board-background.{extension}"),
image.bytes.as_slice(),
"记录拼消消底图图片失败",
);
self.write_json(
"responses/board-background.json",
&json!({
"taskId": task_id,
"actualPrompt": actual_prompt,
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
}),
"记录拼消消底图 response 摘要失败",
);
}
fn record_board_background_error(&self, error: &AppError) {
self.write_json(
"responses/board-background.error.json",
&puzzle_clear_debug_error_json(error),
"记录拼消消底图错误失败",
);
}
fn record_sheet_request(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
prompt: &str,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
self.write_text(
format!("prompts/{}.txt", sheet_spec.sheet_id),
prompt,
"记录拼消消 sheet prompt 失败",
);
self.write_json(
format!("requests/{}-{attempt_id}.json", sheet_spec.sheet_id),
&json!({
"endpoint": "/v1/images/generations",
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"body": build_openai_image_request_body(
prompt,
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
1,
&[],
),
}),
"记录拼消消 sheet request 失败",
);
}
fn record_sheet_generation_error(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
error: &AppError,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
self.write_json(
format!("responses/{}-{attempt_id}.error.json", sheet_spec.sheet_id),
&json!({
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"stage": "generation",
"error": puzzle_clear_debug_error_json(error),
}),
"记录拼消消 sheet 生成错误失败",
);
}
fn record_sheet_attempt_image(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
task_id: &str,
actual_prompt: Option<&str>,
image: &DownloadedOpenAiImage,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
let extension = puzzle_clear_debug_image_extension(image);
self.write_bytes(
format!("sheets/{}-{attempt_id}.{extension}", sheet_spec.sheet_id),
image.bytes.as_slice(),
"记录拼消消 sheet 原图失败",
);
self.write_json(
format!("responses/{}-{attempt_id}.json", sheet_spec.sheet_id),
&json!({
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"taskId": task_id,
"actualPrompt": actual_prompt,
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
}),
"记录拼消消 sheet response 摘要失败",
);
}
fn record_sheet_quality(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
task_id: &str,
image: &DownloadedOpenAiImage,
quality_error: Option<&AppError>,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
match build_puzzle_clear_sheet_quality_debug_report(
sheet_spec,
attempt_index,
task_id,
image,
quality_error,
) {
Ok(report) => {
self.write_json(
format!("reports/{}-{attempt_id}.quality.json", sheet_spec.sheet_id),
&report,
"记录拼消消 sheet 质量报告失败",
);
}
Err(error) => self.write_json(
format!("reports/{}-{attempt_id}.quality-error.json", sheet_spec.sheet_id),
&puzzle_clear_debug_error_json(&error),
"记录拼消消 sheet 质量报告错误失败",
),
}
self.write_sheet_cells(sheet_spec, attempt_index, image);
}
fn record_sheet_accepted(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
task_id: &str,
image: &DownloadedOpenAiImage,
) {
let extension = puzzle_clear_debug_image_extension(image);
self.write_bytes(
format!("accepted/{}.{extension}", sheet_spec.sheet_id),
image.bytes.as_slice(),
"记录拼消消 accepted sheet 失败",
);
self.write_json(
format!("accepted/{}.json", sheet_spec.sheet_id),
&json!({
"sheetId": sheet_spec.sheet_id,
"taskId": task_id,
"accepted": true,
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
}),
"记录拼消消 accepted sheet 摘要失败",
);
}
fn record_atlas_image(
&self,
image: &DownloadedOpenAiImage,
generated_sheets: &[PuzzleClearGeneratedSheet],
) {
self.write_bytes(
"atlas/puzzle-clear-atlas.png",
image.bytes.as_slice(),
"记录拼消消最终 atlas 失败",
);
self.write_json(
"atlas/puzzle-clear-atlas.json",
&json!({
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
"acceptedSheets": generated_sheets
.iter()
.map(|sheet| json!({
"sheetId": sheet.spec.sheet_id,
"taskId": sheet.task_id.as_str(),
}))
.collect::<Vec<_>>(),
}),
"记录拼消消最终 atlas 摘要失败",
);
}
fn record_summary_success(
&self,
session_id: &str,
profile_id: &str,
sheet_count: usize,
card_count: usize,
) {
self.write_text(
"summary.md",
format!(
concat!(
"# 拼消消 runtime 生图调试\n\n",
"- runId: `{}`\n",
"- sessionId: `{}`\n",
"- profileId: `{}`\n",
"- status: `ready`\n",
"- acceptedSheets: `{}`\n",
"- cardCount: `{}`\n\n",
"关键查看顺序:\n\n",
"1. `prompts/` 和 `requests/`:真实请求内容。\n",
"2. `sheets/`:每次 attempt 的原始 sheet。\n",
"3. `reports/*.quality.json`:每格质量指标和 hard/advisory findings。\n",
"4. `cells/<sheet-attempt>/contact-sheet.png`4x6 裁切总览。\n",
"5. `accepted/`:最终通过门禁的 sheet。\n",
"6. `atlas/puzzle-clear-atlas.png`:最终 atlas 预览。\n"
),
self.run_id, session_id, profile_id, sheet_count, card_count
),
"记录拼消消调试 summary 失败",
);
}
fn write_sheet_cells(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
image: &DownloadedOpenAiImage,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
let result = (|| -> Result<(), AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试裁切解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
let mut contact = image::RgbaImage::from_pixel(
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_COLUMNS,
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_ROWS,
image::Rgba([255, 255, 255, 0]),
);
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds =
puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let cropped = source
.crop_imm(bounds.x0, bounds.y0, bounds.width(), bounds.height())
.resize_exact(
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
image::imageops::FilterType::Lanczos3,
)
.to_rgba8();
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(cropped.clone())
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试裁切写入失败:{error}", sheet_spec.sheet_id),
}))
})?;
self.write_bytes(
format!(
"cells/{}-{attempt_id}/r{:02}-c{:02}-{}.png",
sheet_spec.sheet_id,
row + 1,
col + 1,
sanitize_puzzle_clear_debug_segment(group_id, "cell"),
),
cursor.into_inner().as_slice(),
"记录拼消消 sheet 裁切格失败",
);
image::imageops::overlay(
&mut contact,
&cropped,
i64::from(col * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
i64::from(row * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
);
}
}
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(contact)
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试 contact sheet 写入失败:{error}", sheet_spec.sheet_id),
}))
})?;
self.write_bytes(
format!("cells/{}-{attempt_id}/contact-sheet.png", sheet_spec.sheet_id),
cursor.into_inner().as_slice(),
"记录拼消消 sheet contact sheet 失败",
);
Ok(())
})();
if let Err(error) = result {
self.write_json(
format!("cells/{}-{attempt_id}/error.json", sheet_spec.sheet_id),
&puzzle_clear_debug_error_json(&error),
"记录拼消消 sheet 裁切错误失败",
);
}
}
fn write_text(
&self,
relative_path: impl AsRef<FsPath>,
content: impl AsRef<str>,
action: &'static str,
) {
self.write_result(action, || {
let target = self.root.join(relative_path.as_ref());
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, content.as_ref().as_bytes())
});
}
fn write_json(&self, relative_path: impl AsRef<FsPath>, value: &Value, action: &'static str) {
self.write_result(action, || {
let target = self.root.join(relative_path.as_ref());
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let mut bytes = serde_json::to_vec_pretty(value)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?;
bytes.push(b'\n');
fs::write(target, bytes)
});
}
fn write_bytes(
&self,
relative_path: impl AsRef<FsPath>,
bytes: &[u8],
action: &'static str,
) {
self.write_result(action, || {
let target = self.root.join(relative_path.as_ref());
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, bytes)
});
}
fn write_result(
&self,
action: &'static str,
operation: impl FnOnce() -> std::io::Result<()>,
) {
if let Err(error) = operation() {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
run_id = self.run_id,
debug_path = %self.root.display(),
error = %error,
action,
"拼消消本地生图调试包写入失败,已忽略"
);
}
}
}
fn maybe_create_puzzle_clear_image_debug_run(
session_id: &str,
profile_id: &str,
theme_prompt: &str,
) -> Option<PuzzleClearImageDebugRun> {
if !puzzle_clear_image_debug_enabled() {
return None;
}
let base_dir = puzzle_clear_image_debug_runs_dir();
let run_id = env::var("PUZZLE_CLEAR_IMAGE_DEBUG_RUN_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
format!(
"runtime-{}-{}-{}",
sanitize_puzzle_clear_debug_segment(session_id, "session"),
sanitize_puzzle_clear_debug_segment(profile_id, "profile"),
current_utc_micros()
)
});
let run_id = sanitize_puzzle_clear_debug_segment(run_id.as_str(), "runtime");
let root = base_dir.join(run_id.as_str());
if let Err(error) = fs::create_dir_all(&root) {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
debug_path = %root.display(),
error = %error,
"拼消消本地生图调试包初始化失败,已关闭本次记录"
);
return None;
}
let debug_run = PuzzleClearImageDebugRun { root, run_id };
debug_run.write_json(
"manifest.json",
&json!({
"runId": debug_run.run_id.as_str(),
"source": "api-server-runtime",
"playType": "puzzle-clear",
"sessionId": session_id,
"profileId": profile_id,
"themePrompt": theme_prompt,
"createdAt": format_timestamp_micros(current_utc_micros()),
"env": {
"PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED": "1",
"PUZZLE_CLEAR_IMAGE_DEBUG_DIR": puzzle_clear_image_debug_runs_dir().display().to_string(),
},
}),
"记录拼消消调试 manifest 失败",
);
if let Err(error) = fs::write(
puzzle_clear_image_debug_runs_dir().join("latest-runtime.txt"),
debug_run.root.to_string_lossy().as_bytes(),
) {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
error = %error,
"拼消消本地生图调试包 latest-runtime.txt 写入失败,已忽略"
);
}
tracing::info!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
run_id = debug_run.run_id.as_str(),
debug_path = %debug_run.root.display(),
"拼消消本地生图调试包已开启"
);
Some(debug_run)
}
fn puzzle_clear_image_debug_enabled() -> bool {
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
fn puzzle_clear_image_debug_runs_dir() -> PathBuf {
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
puzzle_clear_repo_root()
.join(".app")
.join("puzzle-clear-image-debug")
.join("runs")
})
}
fn puzzle_clear_repo_root() -> PathBuf {
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
for candidate in current_dir.ancestors() {
if candidate.join("server-rs").is_dir() && candidate.join("package.json").is_file() {
return candidate.to_path_buf();
}
}
current_dir
}
fn puzzle_clear_debug_attempt_id(attempt_index: usize) -> String {
format!("attempt-{:02}", attempt_index + 1)
}
fn puzzle_clear_debug_image_extension(image: &DownloadedOpenAiImage) -> String {
sanitize_puzzle_clear_debug_segment(
image
.extension
.trim()
.trim_start_matches('.')
.to_ascii_lowercase()
.as_str(),
"png",
)
}
fn sanitize_puzzle_clear_debug_segment(raw: &str, fallback: &str) -> String {
let mut value = String::new();
for character in raw.chars() {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
value.push(character);
} else {
value.push('-');
}
}
let value = value.trim_matches('-').trim_matches('.').to_string();
if value.is_empty() {
fallback.to_string()
} else {
value
}
}
fn puzzle_clear_debug_error_json(error: &AppError) -> Value {
json!({
"statusCode": error.status_code().as_u16(),
"code": error.code(),
"message": error.body_text(),
"details": error.details(),
})
}
fn build_puzzle_clear_sheet_quality_debug_report(
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
task_id: &str,
image: &DownloadedOpenAiImage,
quality_error: Option<&AppError>,
) -> Result<Value, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试质量报告解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
let mut hard_findings = Vec::new();
let mut advisory_findings = Vec::new();
let mut cells = Vec::new();
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let quality =
analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds);
let cell_label = format!("{}行第{}", row + 1, col + 1);
let mut cell_findings = Vec::new();
let mut cell_advisory_findings = Vec::new();
if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL {
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
let finding = format!("{cell_label} 空白格有主体");
hard_findings.push(finding.clone());
cell_findings.push(finding);
}
} else if group_id != PUZZLE_CLEAR_SHEET_FILLER_CELL {
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
let finding = format!("{cell_label} 主体过少");
hard_findings.push(finding.clone());
cell_findings.push(finding);
}
if quality.strongest_internal_seam_ratio
> PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
{
let finding = format!("{cell_label} 单格内部疑似拼接线");
hard_findings.push(finding.clone());
cell_findings.push(finding);
}
if quality.exposed_edge_count >= 2
&& quality.strongest_edge_ratio
> PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
{
let finding = format!("{cell_label} 主体贴到不同图案边界");
advisory_findings.push(finding.clone());
cell_advisory_findings.push(finding);
}
}
cells.push(json!({
"row": row + 1,
"col": col + 1,
"groupId": group_id,
"discarded": is_puzzle_clear_sheet_discarded_cell(group_id),
"bounds": {
"x0": bounds.x0,
"y0": bounds.y0,
"x1": bounds.x1,
"y1": bounds.y1,
},
"foregroundRatio": quality.foreground_ratio,
"exposedEdgeCount": quality.exposed_edge_count,
"strongestEdgeRatio": quality.strongest_edge_ratio,
"strongestInternalSeamRatio": quality.strongest_internal_seam_ratio,
"hardFindings": cell_findings,
"advisoryFindings": cell_advisory_findings,
}));
}
}
Ok(json!({
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"taskId": task_id,
"accepted": quality_error.is_none(),
"image": {
"width": source_width,
"height": source_height,
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
"thresholds": {
"foregroundDiff": PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD,
"minForegroundRatio": PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO,
"blankMaxForegroundRatio": PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO,
"edgeRatio": PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD,
"strongEdgeRatio": PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD,
"internalSeamDiff": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD,
"internalSeamRatio": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD,
"internalSeamSideContrast": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD,
"internalSeamSideTextureMax": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX,
},
"hardFindings": hard_findings,
"advisoryFindings": advisory_findings,
"qualityError": quality_error.map(puzzle_clear_debug_error_json),
"cells": cells,
}))
}
async fn maybe_prepare_puzzle_clear_assets_inner(
state: &AppState,
request_context: &RequestContext,
@@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
.map(ToString::to_string)
.unwrap_or_else(|| {
build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX)
});
});
payload.profile_id = Some(profile_id.clone());
let image_debug_run = maybe_create_puzzle_clear_image_debug_run(
session_id,
profile_id.as_str(),
payload.theme_prompt.as_deref().unwrap_or_default(),
);
if payload.generate_board_background.unwrap_or(false)
&& payload
@@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
owner_user_id,
profile_id.as_str(),
board_background_prompt.unwrap_or(theme_prompt),
image_debug_run.as_ref(),
)
.await?;
payload.board_background_asset = Some(background_asset);
@@ -683,6 +1406,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
.map(|group| (group.group_id.clone(), group))
.collect::<BTreeMap<_, _>>();
let sheet_specs = puzzle_clear_atlas_sheet_specs();
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_spec(&sheet_specs, &groups);
}
let settings = require_openai_image_settings(state)
.map(|settings| {
settings.with_external_api_audit_context(
@@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
sheet_spec.sheet_id,
attempt_index + 1
);
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_request(&sheet_spec, attempt_index, sheet_prompt.as_str());
}
let generated = match create_openai_image_generation(
&http_client,
&settings,
@@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
{
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
}
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
@@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
continue;
}
Err(error) => {
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
}
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
@@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
}
};
let task_id = generated.task_id;
let actual_prompt = generated.actual_prompt;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
@@ -755,8 +1491,30 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
})),
)
})?;
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_attempt_image(
&sheet_spec,
attempt_index,
task_id.as_str(),
actual_prompt.as_deref(),
&image,
);
}
let quality_result = validate_puzzle_clear_sheet_quality(&image, &sheet_spec);
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_quality(
&sheet_spec,
attempt_index,
task_id.as_str(),
&image,
quality_result.as_ref().err(),
);
}
match quality_result {
Ok(()) => {
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image);
}
accepted_sheet = Some(PuzzleClearGeneratedSheet {
spec: sheet_spec,
prompt: sheet_prompt.clone(),
@@ -821,6 +1579,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_atlas_image(&atlas_image, &generated_sheets);
}
let atlas_prompt = generated_sheets
.iter()
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
@@ -864,6 +1625,14 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
payload.atlas_asset = Some(atlas_asset);
payload.pattern_groups = Some(groups);
payload.card_assets = Some(card_assets);
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_summary_success(
session_id,
profile_id.as_str(),
generated_sheets.len(),
payload.card_assets.as_ref().map_or(0, Vec::len),
);
}
tracing::info!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id,
@@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background(
owner_user_id: &str,
profile_id: &str,
theme_prompt: &str,
image_debug_run: Option<&PuzzleClearImageDebugRun>,
) -> Result<PuzzleClearImageAsset, Response> {
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
if let Some(debug_run) = image_debug_run {
debug_run.record_board_background_request(prompt.as_str());
}
let settings = require_openai_image_settings(state)
.map(|settings| {
settings.with_external_api_audit_context(
@@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background(
)
.await
.map_err(|error| {
if let Some(debug_run) = image_debug_run {
debug_run.record_board_background_error(&error);
}
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let task_id = generated.task_id;
let actual_prompt = generated.actual_prompt;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
@@ -1095,6 +1872,9 @@ async fn generate_and_persist_puzzle_clear_board_background(
})),
)
})?;
if let Some(debug_run) = image_debug_run {
debug_run.record_board_background_image(task_id.as_str(), actual_prompt.as_deref(), &image);
}
persist_puzzle_clear_generated_image_asset(
state,
owner_user_id,
@@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
if value.contains("发布需要")
|| value.contains("不能为空")
|| value.contains("必须")
|| value.contains("无权") =>
|| value.contains("无权")
|| value.contains("puzzle-clear 坐标无效")
|| value.contains("puzzle-clear 目标格子没有卡牌")
|| value.contains("puzzle-clear 当前 run 不在 playing 状态")
|| value.contains("puzzle-clear 当前关卡已经超时") =>
{
StatusCode::BAD_REQUEST
}

View File

@@ -3,25 +3,55 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
use shared_kernel::normalize_required_string;
use crate::{
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell,
PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig,
PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot,
PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota,
PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination,
PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation,
PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind,
PuzzleClearShapeQuota,
};
pub fn puzzle_clear_level_configs() -> Vec<PuzzleClearLevelConfig> {
vec![PuzzleClearLevelConfig {
level_index: 1,
board_size: 6,
target_clears: 35,
duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
PuzzleClearShapeKind::TwoByThree,
],
}]
vec![
PuzzleClearLevelConfig {
level_index: 1,
board_size: 6,
target_clears: 15,
duration_seconds: 300,
unlocked_shapes: vec![PuzzleClearShapeKind::OneByTwo],
},
PuzzleClearLevelConfig {
level_index: 2,
board_size: 6,
target_clears: 20,
duration_seconds: 300,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
],
},
PuzzleClearLevelConfig {
level_index: 3,
board_size: 6,
target_clears: 30,
duration_seconds: 420,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
],
},
PuzzleClearLevelConfig {
level_index: 4,
board_size: 6,
target_clears: 35,
duration_seconds: 600,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
PuzzleClearShapeKind::TwoByThree,
],
},
]
}
pub fn puzzle_clear_shape_quotas() -> Vec<PuzzleClearShapeQuota> {
@@ -114,7 +144,7 @@ pub fn create_puzzle_clear_board(
return Err(PuzzleClearError::InvalidLevel);
}
let total = (level.board_size * level.board_size) as usize;
if cards.len() < total {
if cards.is_empty() {
return Err(PuzzleClearError::EmptyDeck);
}
let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index));
@@ -125,10 +155,12 @@ pub fn create_puzzle_clear_board(
for row in 0..level.board_size {
for col in 0..level.board_size {
let index = (row * level.board_size + col) as usize;
let empty_slots = total.saturating_sub(selected.len());
let card_index = index.checked_sub(empty_slots);
cells.push(PuzzleClearCell {
row,
col,
card: selected.get(index).cloned(),
card: card_index.and_then(|card_index| selected.get(card_index).cloned()),
locked_group_id: None,
});
}
@@ -202,7 +234,7 @@ pub fn apply_puzzle_clear_swap(
.into_iter()
.find(|config| config.level_index == next.level_index)
.ok_or(PuzzleClearError::InvalidLevel)?;
if next.clears_done >= level.target_clears && !has_remaining_cards(&next.board) {
if next.clears_done >= level.target_clears && !has_remaining_cards_in_run(&next) {
next.status = if next.level_index >= max_puzzle_clear_level_index() {
PuzzleClearRunStatus::Finished
} else {
@@ -401,8 +433,13 @@ fn ensure_not_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> Result<(), P
}
fn is_level_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> bool {
let duration_seconds = puzzle_clear_level_configs()
.into_iter()
.find(|config| config.level_index == run.level_index)
.map(|config| config.duration_seconds)
.unwrap_or(600);
now_ms.saturating_sub(run.level_started_at_ms)
> u64::from(PUZZLE_CLEAR_LEVEL_DURATION_SECONDS) * 1000
> u64::from(duration_seconds) * 1000
}
fn validate_board(board: &PuzzleClearBoard) -> Result<(), PuzzleClearError> {
@@ -421,6 +458,15 @@ fn has_remaining_cards(board: &PuzzleClearBoard) -> bool {
board.cells.iter().any(|cell| cell.card.is_some())
}
fn has_remaining_cards_in_run(run: &PuzzleClearRunSnapshot) -> bool {
has_remaining_cards(&run.board)
|| run
.deck
.ready_columns
.iter()
.any(|column| !column.is_empty())
}
fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> {
if find_eliminations(board).is_empty() && has_playable_move(board) {
return Ok(());
@@ -490,15 +536,7 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
if entries.len() < 2 || first.shape == PuzzleClearShapeKind::OneByTwo {
return None;
}
let mut ordered = entries.clone();
ordered.sort_by_key(|(_, _, card)| (card.part_y, card.part_x));
let adjacent = ordered.windows(2).all(|pair| {
let a = &pair[0].2;
let b = &pair[1].2;
manhattan_part_distance(a, b) == 1
&& are_neighbors(pair[0].0, pair[0].1, pair[1].0, pair[1].1)
});
adjacent.then(|| PuzzleClearElimination {
is_connected_partial_group(&entries).then(|| PuzzleClearElimination {
group_id,
positions: entries
.into_iter()
@@ -509,6 +547,32 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
.collect()
}
fn is_connected_partial_group(entries: &[(u32, u32, PuzzleClearCard)]) -> bool {
if entries.len() < 2 {
return false;
}
let mut visited = vec![false; entries.len()];
let mut stack = vec![0usize];
visited[0] = true;
while let Some(index) = stack.pop() {
let current = &entries[index];
for (candidate_index, candidate) in entries.iter().enumerate() {
if visited[candidate_index] {
continue;
}
if manhattan_part_distance(&current.2, &candidate.2) == 1
&& are_neighbors(current.0, current.1, candidate.0, candidate.1)
{
visited[candidate_index] = true;
stack.push(candidate_index);
}
}
}
visited.into_iter().all(|is_visited| is_visited)
}
fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) {
for cell in &mut board.cells {
if cell.locked_group_id.as_deref() == Some(group_id) {
@@ -1177,14 +1241,40 @@ mod tests {
use super::*;
#[test]
fn fixed_level_config_uses_single_six_by_six_level() {
fn fixed_level_config_uses_four_six_by_six_levels() {
let configs = puzzle_clear_level_configs();
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].board_size, 6);
assert_eq!(configs[0].target_clears, 35);
assert_eq!(configs.len(), 4);
assert!(configs.iter().all(|config| config.board_size == 6));
assert_eq!(
configs[0].unlocked_shapes,
configs
.iter()
.map(|config| (
config.level_index,
config.target_clears,
config.duration_seconds
))
.collect::<Vec<_>>(),
vec![(1, 15, 300), (2, 20, 300), (3, 30, 420), (4, 35, 600)]
);
assert_eq!(configs[0].unlocked_shapes, vec![PuzzleClearShapeKind::OneByTwo]);
assert_eq!(
configs[1].unlocked_shapes,
vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
]
);
assert_eq!(
configs[2].unlocked_shapes,
vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
]
);
assert_eq!(
configs[3].unlocked_shapes,
vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
@@ -1192,7 +1282,6 @@ mod tests {
PuzzleClearShapeKind::TwoByThree,
]
);
assert!(configs.iter().all(|config| config.duration_seconds == 600));
}
#[test]
@@ -1250,6 +1339,23 @@ mod tests {
assert!(has_playable_move(&board));
}
#[test]
fn first_level_board_uses_exact_target_cards_and_leaves_empty_cells() {
let groups = plan_puzzle_clear_pattern_groups(64).expect("atlas should plan");
let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear")
.into_iter()
.filter(|card| card.shape == PuzzleClearShapeKind::OneByTwo)
.take(30)
.collect::<Vec<_>>();
let board = create_puzzle_clear_board(&puzzle_clear_level_configs()[0], "seed-a", cards)
.expect("board should create with empty cells");
assert_eq!(board.cells.iter().filter(|cell| cell.card.is_some()).count(), 30);
assert_eq!(board.cells.iter().filter(|cell| cell.card.is_none()).count(), 6);
assert!(find_eliminations(&board).is_empty());
assert!(has_playable_move(&board));
}
#[test]
fn one_by_two_neighbors_are_not_half_locked() {
let board = board_from_cards(
@@ -1350,7 +1456,7 @@ mod tests {
}
#[test]
fn reaching_target_clears_without_empty_board_keeps_playing() {
fn reaching_target_clears_does_not_complete_level_with_remaining_cards() {
let board = board_from_cards(
3,
vec![
@@ -1376,7 +1482,7 @@ mod tests {
100,
)
.expect("run should start");
run.clears_done = 4;
run.clears_done = 14;
let next = apply_puzzle_clear_swap(
&run,
PuzzleClearMove {
@@ -1389,11 +1495,57 @@ mod tests {
)
.expect("swap should resolve");
assert_eq!(next.clears_done, 5);
assert_eq!(next.clears_done, 15);
assert_eq!(next.status, PuzzleClearRunStatus::Playing);
assert!(next.finished_at_ms.is_none());
assert!(next.board.cells.iter().any(|cell| cell.card.is_some()));
}
#[test]
fn reaching_target_clears_completes_level_after_all_cards_are_removed() {
let board = board_from_cards(
3,
vec![
Some(card("play", 0, 0)),
None,
None,
None,
Some(card("play", 1, 0)),
None,
None,
None,
None,
],
);
let mut run = start_puzzle_clear_run(
"run-target-empty".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
board,
PuzzleClearDeck {
ready_columns: vec![vec![], vec![], vec![]],
},
100,
)
.expect("run should start");
run.clears_done = 14;
let next = apply_puzzle_clear_swap(
&run,
PuzzleClearMove {
from_row: 1,
from_col: 1,
to_row: 0,
to_col: 1,
},
200,
)
.expect("swap should resolve");
assert_eq!(next.clears_done, 15);
assert_eq!(next.status, PuzzleClearRunStatus::LevelCleared);
assert!(next.board.cells.iter().all(|cell| cell.card.is_none()));
}
#[test]
fn refill_keeps_locked_partial_group_in_place() {
let mut board = board_from_cards(
@@ -1737,6 +1889,57 @@ mod tests {
);
}
#[test]
fn two_by_two_l_shaped_partial_group_locks_as_one_group() {
let board = board_from_cards(
3,
vec![
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 0)),
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 1, 0)),
Some(card("noise-a", 0, 0)),
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 1)),
Some(card("noise-b", 0, 0)),
Some(card("play", 1, 0)),
Some(card("noise-d", 0, 0)),
Some(card("play", 0, 0)),
Some(card("noise-c", 0, 0)),
],
);
let run = start_puzzle_clear_run(
"run-2x2-partial".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
board,
PuzzleClearDeck {
ready_columns: vec![vec![], vec![], vec![]],
},
100,
)
.expect("run should start");
let locked = apply_puzzle_clear_swap(
&run,
PuzzleClearMove {
from_row: 1,
from_col: 1,
to_row: 2,
to_col: 0,
},
200,
)
.expect("non-clear swap should lock partial group");
let block_locks = locked
.board
.cells
.iter()
.filter(|cell| cell.card.as_ref().is_some_and(|card| card.group_id == "block"))
.map(|cell| cell.locked_group_id.as_deref())
.collect::<Vec<_>>();
assert_eq!(block_locks, vec![Some("block"), Some("block"), Some("block")]);
assert_eq!(locked.clears_done, 0);
}
#[test]
fn timeout_fails_only_current_level_and_retry_restarts_it() {
let board = board_from_cards(

View File

@@ -6,8 +6,8 @@ pub use types::*;
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json};
use module_puzzle_clear::{
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck,
PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level,
PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearMove,
PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level,
apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout,
parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs,
retry_puzzle_clear_level, start_puzzle_clear_run,
@@ -976,6 +976,7 @@ fn build_level_board_and_deck(
.collect::<Vec<_>>(),
seed,
level.target_clears as usize,
&level.unlocked_shapes,
);
let board = create_puzzle_clear_board(&level, seed, allowed.clone())
.map_err(|error| error.to_string())?;
@@ -991,6 +992,7 @@ fn ordered_level_cards(
cards: Vec<PuzzleClearCard>,
seed: &str,
target_groups: usize,
unlocked_shapes: &[module_puzzle_clear::PuzzleClearShapeKind],
) -> Vec<PuzzleClearCard> {
let mut groups: BTreeMap<String, Vec<PuzzleClearCard>> = BTreeMap::new();
for card in cards {
@@ -1008,9 +1010,40 @@ fn ordered_level_cards(
.unwrap_or_default();
left_key.cmp(&right_key)
});
grouped
let mut selected = Vec::new();
let mut selected_group_ids = std::collections::BTreeSet::new();
for shape in unlocked_shapes {
if selected.len() >= target_groups {
break;
}
let Some(group) = grouped.iter().find(|group| {
group
.first()
.is_some_and(|card| card.shape == *shape && !selected_group_ids.contains(&card.group_id))
}) else {
continue;
};
if let Some(first) = group.first() {
selected_group_ids.insert(first.group_id.clone());
selected.push(group.clone());
}
}
for group in grouped {
if selected.len() >= target_groups {
break;
}
let Some(first) = group.first() else {
continue;
};
if selected_group_ids.contains(&first.group_id) {
continue;
}
selected_group_ids.insert(first.group_id.clone());
selected.push(group);
}
selected
.into_iter()
.take(target_groups)
.flat_map(|mut group| {
group.sort_by_key(|card| (card.part_y, card.part_x));
group
@@ -1061,7 +1094,7 @@ fn build_runtime_snapshot(
level_index: snapshot.level_index,
clears_done: snapshot.clears_done,
target_clears: level.target_clears,
level_duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS,
level_duration_seconds: level.duration_seconds,
level_started_at_ms: snapshot.level_started_at_ms,
board: PuzzleClearBoardSnapshot {
rows: snapshot.board.rows,